structx-sdk 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.
- structx_sdk/__init__.py +82 -0
- structx_sdk/_client.py +526 -0
- structx_sdk/_exceptions.py +177 -0
- structx_sdk/_models.py +154 -0
- structx_sdk/_version.py +1 -0
- structx_sdk/py.typed +0 -0
- structx_sdk-0.2.0.dist-info/METADATA +217 -0
- structx_sdk-0.2.0.dist-info/RECORD +10 -0
- structx_sdk-0.2.0.dist-info/WHEEL +4 -0
- structx_sdk-0.2.0.dist-info/licenses/LICENSE +21 -0
structx_sdk/__init__.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""struct-x Python SDK — official client for the structured-extraction API.
|
|
2
|
+
|
|
3
|
+
Quickstart:
|
|
4
|
+
|
|
5
|
+
from structx_sdk import StructX
|
|
6
|
+
|
|
7
|
+
client = StructX(api_key="sx_...")
|
|
8
|
+
result = client.extract(
|
|
9
|
+
content="<div>$99 Widget</div>",
|
|
10
|
+
schema={
|
|
11
|
+
"type": "object",
|
|
12
|
+
"properties": {
|
|
13
|
+
"price_cents": {"type": "integer"},
|
|
14
|
+
"title": {"type": "string"},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
)
|
|
18
|
+
print(result.data) # {'price_cents': 9900, 'title': 'Widget'}
|
|
19
|
+
print(result.field_confidences) # [FieldConfidence(field='price_cents', ...)]
|
|
20
|
+
|
|
21
|
+
Async variant — same surface:
|
|
22
|
+
|
|
23
|
+
from structx_sdk import AsyncStructX
|
|
24
|
+
|
|
25
|
+
async with AsyncStructX(api_key="sx_...") as client:
|
|
26
|
+
result = await client.extract(content="...", schema={...})
|
|
27
|
+
"""
|
|
28
|
+
from ._client import AsyncStructX, RetryPolicy, StructX
|
|
29
|
+
from ._exceptions import (
|
|
30
|
+
ApiError,
|
|
31
|
+
AuthenticationError,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
PermissionDeniedError,
|
|
34
|
+
RateLimitError,
|
|
35
|
+
ServerError,
|
|
36
|
+
StructXError,
|
|
37
|
+
TransportError,
|
|
38
|
+
ValidationError,
|
|
39
|
+
)
|
|
40
|
+
from ._models import (
|
|
41
|
+
Extraction,
|
|
42
|
+
FieldConfidence,
|
|
43
|
+
InferenceResult,
|
|
44
|
+
InferredField,
|
|
45
|
+
InferredSchema,
|
|
46
|
+
Model,
|
|
47
|
+
Recommendation,
|
|
48
|
+
Template,
|
|
49
|
+
TokenCounts,
|
|
50
|
+
Usage,
|
|
51
|
+
)
|
|
52
|
+
from ._version import __version__
|
|
53
|
+
|
|
54
|
+
__all__ = [
|
|
55
|
+
# Clients
|
|
56
|
+
"StructX",
|
|
57
|
+
"AsyncStructX",
|
|
58
|
+
"RetryPolicy",
|
|
59
|
+
# Exceptions
|
|
60
|
+
"StructXError",
|
|
61
|
+
"TransportError",
|
|
62
|
+
"ApiError",
|
|
63
|
+
"AuthenticationError",
|
|
64
|
+
"PermissionDeniedError",
|
|
65
|
+
"NotFoundError",
|
|
66
|
+
"ValidationError",
|
|
67
|
+
"RateLimitError",
|
|
68
|
+
"ServerError",
|
|
69
|
+
# Models
|
|
70
|
+
"Extraction",
|
|
71
|
+
"FieldConfidence",
|
|
72
|
+
"TokenCounts",
|
|
73
|
+
"InferenceResult",
|
|
74
|
+
"InferredSchema",
|
|
75
|
+
"InferredField",
|
|
76
|
+
"Recommendation",
|
|
77
|
+
"Template",
|
|
78
|
+
"Model",
|
|
79
|
+
"Usage",
|
|
80
|
+
# Meta
|
|
81
|
+
"__version__",
|
|
82
|
+
]
|
structx_sdk/_client.py
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Sync (`StructX`) and async (`AsyncStructX`) clients.
|
|
2
|
+
|
|
3
|
+
Both share `_BaseClient` for configuration + response parsing + retry
|
|
4
|
+
policy; only the I/O path differs. We intentionally don't subclass
|
|
5
|
+
across sync/async (Python's protocol mismatch makes that messy);
|
|
6
|
+
instead, the response-parsing and retry logic are pure functions
|
|
7
|
+
operating on `httpx.Response`-shaped data, callable from either side.
|
|
8
|
+
|
|
9
|
+
httpx is the underlying HTTP library — supports sync + async with one
|
|
10
|
+
API, ships its own connection pool, and respects PEP 8.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import os
|
|
16
|
+
import platform
|
|
17
|
+
import random
|
|
18
|
+
import sys
|
|
19
|
+
import time
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Mapping
|
|
22
|
+
from urllib.parse import urljoin
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
|
|
26
|
+
from ._exceptions import (
|
|
27
|
+
ApiError,
|
|
28
|
+
AuthenticationError,
|
|
29
|
+
NotFoundError,
|
|
30
|
+
PermissionDeniedError,
|
|
31
|
+
RateLimitError,
|
|
32
|
+
ServerError,
|
|
33
|
+
StructXError,
|
|
34
|
+
TransportError,
|
|
35
|
+
ValidationError,
|
|
36
|
+
)
|
|
37
|
+
from ._models import (
|
|
38
|
+
Extraction,
|
|
39
|
+
InferenceResult,
|
|
40
|
+
Model,
|
|
41
|
+
Template,
|
|
42
|
+
Usage,
|
|
43
|
+
)
|
|
44
|
+
from ._version import __version__
|
|
45
|
+
|
|
46
|
+
_DEFAULT_BASE_URL = "https://api.structx.ai"
|
|
47
|
+
_USER_AGENT = (
|
|
48
|
+
f"structx-sdk/{__version__} "
|
|
49
|
+
f"httpx/{httpx.__version__} "
|
|
50
|
+
f"Python/{platform.python_version()}"
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Endpoints that mutate / bill — retry only on TransportError, never
|
|
54
|
+
# on 5xx (the server may have partially processed before failing).
|
|
55
|
+
_WRITE_PATHS = frozenset({"/v1/extract", "/v1/extract/batch", "/v1/schemas/infer"})
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Retry policy ────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class RetryPolicy:
|
|
63
|
+
"""Controls retry behavior for transient failures.
|
|
64
|
+
|
|
65
|
+
Defaults are conservative: 3 attempts total, exponential backoff
|
|
66
|
+
capped at 30s, 5xx-retry enabled for reads but disabled for writes
|
|
67
|
+
(the SDK enforces the write-exclusion at call sites; this flag only
|
|
68
|
+
affects reads). `respect_retry_after` lets the server tell us
|
|
69
|
+
when to come back — used by 429s.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
max_attempts: int = 3
|
|
73
|
+
initial_backoff: float = 1.0
|
|
74
|
+
max_backoff: float = 30.0
|
|
75
|
+
retry_on_5xx: bool = True
|
|
76
|
+
respect_retry_after: bool = True
|
|
77
|
+
jitter: float = 0.2
|
|
78
|
+
|
|
79
|
+
def backoff_for(self, attempt: int, retry_after: float | None = None) -> float:
|
|
80
|
+
"""Seconds to sleep before `attempt` (1-indexed). If the server
|
|
81
|
+
sent `Retry-After`, use that — caps still apply."""
|
|
82
|
+
if retry_after is not None and self.respect_retry_after:
|
|
83
|
+
return min(retry_after, self.max_backoff)
|
|
84
|
+
base = self.initial_backoff * (2 ** (attempt - 1))
|
|
85
|
+
jitter_range = base * self.jitter
|
|
86
|
+
return min(base + random.uniform(-jitter_range, jitter_range), self.max_backoff)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ── Base client (shared config + parsing) ────────────────────
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
_EXC_BY_STATUS: dict[int, type[ApiError]] = {
|
|
93
|
+
401: AuthenticationError,
|
|
94
|
+
403: PermissionDeniedError,
|
|
95
|
+
404: NotFoundError,
|
|
96
|
+
400: ValidationError,
|
|
97
|
+
422: ValidationError,
|
|
98
|
+
429: RateLimitError,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class _BaseClient:
|
|
104
|
+
api_key: str
|
|
105
|
+
base_url: str = _DEFAULT_BASE_URL
|
|
106
|
+
timeout: float = 30.0
|
|
107
|
+
retry: RetryPolicy = field(default_factory=RetryPolicy)
|
|
108
|
+
default_headers: Mapping[str, str] = field(default_factory=dict)
|
|
109
|
+
|
|
110
|
+
def __post_init__(self) -> None:
|
|
111
|
+
if not self.api_key:
|
|
112
|
+
raise StructXError(
|
|
113
|
+
"api_key is required. Pass it directly or set the "
|
|
114
|
+
"STRUCTX_API_KEY environment variable."
|
|
115
|
+
)
|
|
116
|
+
# Normalize base_url so urljoin works predictably with leading-
|
|
117
|
+
# slash paths.
|
|
118
|
+
self.base_url = self.base_url.rstrip("/") + "/"
|
|
119
|
+
|
|
120
|
+
def _headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
|
121
|
+
h = {
|
|
122
|
+
"X-API-Key": self.api_key,
|
|
123
|
+
"User-Agent": _USER_AGENT,
|
|
124
|
+
"Accept": "application/json",
|
|
125
|
+
}
|
|
126
|
+
h.update(self.default_headers)
|
|
127
|
+
if extra:
|
|
128
|
+
h.update(extra)
|
|
129
|
+
return h
|
|
130
|
+
|
|
131
|
+
def _url(self, path: str) -> str:
|
|
132
|
+
# urljoin requires trailing-slash on base; we ensured that in
|
|
133
|
+
# __post_init__. Path may be absolute (/v1/...) or relative.
|
|
134
|
+
return urljoin(self.base_url, path.lstrip("/"))
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def _parse_response(response: httpx.Response) -> dict[str, Any]:
|
|
138
|
+
"""Translate a raw HTTP response into either the JSON body or
|
|
139
|
+
an appropriate typed exception. Single source of truth for
|
|
140
|
+
error mapping — both sync and async paths route through here."""
|
|
141
|
+
request_id = response.headers.get("x-request-id") or response.headers.get(
|
|
142
|
+
"request-id"
|
|
143
|
+
)
|
|
144
|
+
if 200 <= response.status_code < 300:
|
|
145
|
+
if not response.content:
|
|
146
|
+
return {}
|
|
147
|
+
try:
|
|
148
|
+
return response.json()
|
|
149
|
+
except ValueError as e:
|
|
150
|
+
raise ApiError(
|
|
151
|
+
f"Server returned a non-JSON 2xx response: {e}",
|
|
152
|
+
status_code=response.status_code,
|
|
153
|
+
request_id=request_id,
|
|
154
|
+
) from e
|
|
155
|
+
|
|
156
|
+
# Error path. Backend convention is {"error": str, "code": str}.
|
|
157
|
+
body: dict[str, Any] = {}
|
|
158
|
+
try:
|
|
159
|
+
body = response.json() if response.content else {}
|
|
160
|
+
except ValueError:
|
|
161
|
+
pass
|
|
162
|
+
|
|
163
|
+
message = body.get("error") or response.text or response.reason_phrase
|
|
164
|
+
code = body.get("code")
|
|
165
|
+
exc_cls = _EXC_BY_STATUS.get(response.status_code)
|
|
166
|
+
if exc_cls is None:
|
|
167
|
+
exc_cls = ServerError if response.status_code >= 500 else ApiError
|
|
168
|
+
|
|
169
|
+
if exc_cls is RateLimitError:
|
|
170
|
+
retry_after_header = response.headers.get("retry-after")
|
|
171
|
+
try:
|
|
172
|
+
retry_after = float(retry_after_header) if retry_after_header else None
|
|
173
|
+
except ValueError:
|
|
174
|
+
retry_after = None
|
|
175
|
+
raise RateLimitError(
|
|
176
|
+
message,
|
|
177
|
+
status_code=response.status_code,
|
|
178
|
+
code=code,
|
|
179
|
+
response_body=body,
|
|
180
|
+
request_id=request_id,
|
|
181
|
+
retry_after=retry_after,
|
|
182
|
+
credits_used=body.get("credits_used"),
|
|
183
|
+
credits_remaining=body.get("credits_remaining"),
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
raise exc_cls(
|
|
187
|
+
message,
|
|
188
|
+
status_code=response.status_code,
|
|
189
|
+
code=code,
|
|
190
|
+
response_body=body,
|
|
191
|
+
request_id=request_id,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
def _is_retryable_response(self, path: str, status: int) -> bool:
|
|
195
|
+
"""5xx retry rule. Writes never retry on 5xx — see _WRITE_PATHS
|
|
196
|
+
and the docstring on TransportError."""
|
|
197
|
+
if not self.retry.retry_on_5xx:
|
|
198
|
+
return False
|
|
199
|
+
if path in _WRITE_PATHS:
|
|
200
|
+
return False
|
|
201
|
+
return 500 <= status < 600 and status != 501 # Not Implemented isn't transient
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Sync client ──────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class StructX(_BaseClient):
|
|
208
|
+
"""Synchronous client.
|
|
209
|
+
|
|
210
|
+
Example:
|
|
211
|
+
>>> from structx_sdk import StructX
|
|
212
|
+
>>> client = StructX(api_key="sx_...")
|
|
213
|
+
>>> result = client.extract(
|
|
214
|
+
... content="<div>$99 Widget</div>",
|
|
215
|
+
... schema={"type": "object", "properties": {
|
|
216
|
+
... "price_cents": {"type": "integer"},
|
|
217
|
+
... "title": {"type": "string"},
|
|
218
|
+
... }},
|
|
219
|
+
... )
|
|
220
|
+
>>> result.data
|
|
221
|
+
{'price_cents': 9900, 'title': 'Widget'}
|
|
222
|
+
>>> result.field_confidences[0].confidence
|
|
223
|
+
0.92
|
|
224
|
+
|
|
225
|
+
Picks up `STRUCTX_API_KEY` and `STRUCTX_BASE_URL` from the
|
|
226
|
+
environment if not passed explicitly:
|
|
227
|
+
|
|
228
|
+
>>> client = StructX.from_env()
|
|
229
|
+
"""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self,
|
|
233
|
+
api_key: str | None = None,
|
|
234
|
+
*,
|
|
235
|
+
base_url: str | None = None,
|
|
236
|
+
timeout: float = 30.0,
|
|
237
|
+
retry: RetryPolicy | None = None,
|
|
238
|
+
default_headers: Mapping[str, str] | None = None,
|
|
239
|
+
_http: httpx.Client | None = None,
|
|
240
|
+
) -> None:
|
|
241
|
+
super().__init__(
|
|
242
|
+
api_key=api_key or os.environ.get("STRUCTX_API_KEY", ""),
|
|
243
|
+
base_url=base_url or os.environ.get("STRUCTX_BASE_URL", _DEFAULT_BASE_URL),
|
|
244
|
+
timeout=timeout,
|
|
245
|
+
retry=retry or RetryPolicy(),
|
|
246
|
+
default_headers=default_headers or {},
|
|
247
|
+
)
|
|
248
|
+
self._http = _http or httpx.Client(timeout=timeout)
|
|
249
|
+
self._owns_http = _http is None
|
|
250
|
+
|
|
251
|
+
@classmethod
|
|
252
|
+
def from_env(cls, **overrides: Any) -> "StructX":
|
|
253
|
+
return cls(**overrides)
|
|
254
|
+
|
|
255
|
+
def __enter__(self) -> "StructX":
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def __exit__(self, *exc: Any) -> None:
|
|
259
|
+
self.close()
|
|
260
|
+
|
|
261
|
+
def close(self) -> None:
|
|
262
|
+
if self._owns_http:
|
|
263
|
+
self._http.close()
|
|
264
|
+
|
|
265
|
+
# ── Internal request loop ────────────────────────────────
|
|
266
|
+
|
|
267
|
+
def _request(
|
|
268
|
+
self,
|
|
269
|
+
method: str,
|
|
270
|
+
path: str,
|
|
271
|
+
*,
|
|
272
|
+
json: Any = None,
|
|
273
|
+
params: Mapping[str, Any] | None = None,
|
|
274
|
+
headers: Mapping[str, str] | None = None,
|
|
275
|
+
) -> dict[str, Any]:
|
|
276
|
+
last_exc: Exception | None = None
|
|
277
|
+
for attempt in range(1, self.retry.max_attempts + 1):
|
|
278
|
+
try:
|
|
279
|
+
response = self._http.request(
|
|
280
|
+
method=method,
|
|
281
|
+
url=self._url(path),
|
|
282
|
+
headers=self._headers(headers),
|
|
283
|
+
json=json,
|
|
284
|
+
params=params,
|
|
285
|
+
)
|
|
286
|
+
except (httpx.TransportError, httpx.TimeoutException) as e:
|
|
287
|
+
last_exc = TransportError(f"{type(e).__name__}: {e}")
|
|
288
|
+
if attempt < self.retry.max_attempts:
|
|
289
|
+
time.sleep(self.retry.backoff_for(attempt))
|
|
290
|
+
continue
|
|
291
|
+
raise last_exc from e
|
|
292
|
+
|
|
293
|
+
if attempt < self.retry.max_attempts and self._is_retryable_response(
|
|
294
|
+
path, response.status_code
|
|
295
|
+
):
|
|
296
|
+
retry_after = response.headers.get("retry-after")
|
|
297
|
+
try:
|
|
298
|
+
ra = float(retry_after) if retry_after else None
|
|
299
|
+
except ValueError:
|
|
300
|
+
ra = None
|
|
301
|
+
time.sleep(self.retry.backoff_for(attempt, retry_after=ra))
|
|
302
|
+
continue
|
|
303
|
+
|
|
304
|
+
return self._parse_response(response)
|
|
305
|
+
|
|
306
|
+
# The loop always returns or raises; this is unreachable.
|
|
307
|
+
raise last_exc or StructXError("retry loop exhausted with no response")
|
|
308
|
+
|
|
309
|
+
# ── Public API ───────────────────────────────────────────
|
|
310
|
+
|
|
311
|
+
def extract(
|
|
312
|
+
self,
|
|
313
|
+
content: str,
|
|
314
|
+
*,
|
|
315
|
+
schema: dict[str, Any] | None = None,
|
|
316
|
+
template_slug: str | None = None,
|
|
317
|
+
tier: str = "required",
|
|
318
|
+
options: dict[str, Any] | None = None,
|
|
319
|
+
) -> Extraction:
|
|
320
|
+
"""Run a structured extraction against `content`.
|
|
321
|
+
|
|
322
|
+
Pass EXACTLY ONE of `schema` (inline JSON Schema) or
|
|
323
|
+
`template_slug` (a catalog template like `"logs.stripe.event"`).
|
|
324
|
+
The backend enforces this; passing both raises `ValidationError`.
|
|
325
|
+
|
|
326
|
+
`tier` selects field depth: `"required"` is the cheapest /
|
|
327
|
+
narrowest extraction; `"extended"` returns every field in the
|
|
328
|
+
schema. See backend docs for the full tier ladder.
|
|
329
|
+
|
|
330
|
+
`options` is forwarded as-is to the backend — use it for
|
|
331
|
+
`include_citations`, `use_cache`, `confidence_threshold`, etc.
|
|
332
|
+
"""
|
|
333
|
+
body: dict[str, Any] = {"content": content, "tier": tier}
|
|
334
|
+
if schema is not None:
|
|
335
|
+
body["schema"] = schema
|
|
336
|
+
if template_slug is not None:
|
|
337
|
+
body["template_slug"] = template_slug
|
|
338
|
+
if options is not None:
|
|
339
|
+
body["options"] = options
|
|
340
|
+
return Extraction.model_validate(self._request("POST", "/v1/extract", json=body))
|
|
341
|
+
|
|
342
|
+
def infer_schema(
|
|
343
|
+
self,
|
|
344
|
+
content: str,
|
|
345
|
+
*,
|
|
346
|
+
content_type: str | None = None,
|
|
347
|
+
hints: dict[str, Any] | None = None,
|
|
348
|
+
k: int = 5,
|
|
349
|
+
return_recommendations: bool = True,
|
|
350
|
+
) -> InferenceResult:
|
|
351
|
+
"""Infer a JSON Schema from raw content + optionally return
|
|
352
|
+
template recommendations that match. Costs `infer_min_credits`
|
|
353
|
+
per call (configured on the backend; defaults to 3-5)."""
|
|
354
|
+
body: dict[str, Any] = {
|
|
355
|
+
"content": content,
|
|
356
|
+
"k": k,
|
|
357
|
+
"return_recommendations": return_recommendations,
|
|
358
|
+
}
|
|
359
|
+
if content_type is not None:
|
|
360
|
+
body["content_type"] = content_type
|
|
361
|
+
if hints is not None:
|
|
362
|
+
body["hints"] = hints
|
|
363
|
+
return InferenceResult.model_validate(
|
|
364
|
+
self._request("POST", "/v1/schemas/infer", json=body)
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
def list_templates(self) -> list[Template]:
|
|
368
|
+
"""Public template gallery."""
|
|
369
|
+
raw = self._request("GET", "/v1/schemas")
|
|
370
|
+
items = raw if isinstance(raw, list) else raw.get("templates", [])
|
|
371
|
+
return [Template.model_validate(t) for t in items]
|
|
372
|
+
|
|
373
|
+
def list_models(self) -> list[Model]:
|
|
374
|
+
"""Available models. Most callers don't need this — the
|
|
375
|
+
backend's router picks per call."""
|
|
376
|
+
raw = self._request("GET", "/v1/models")
|
|
377
|
+
items = raw if isinstance(raw, list) else raw.get("models", [])
|
|
378
|
+
return [Model.model_validate(m) for m in items]
|
|
379
|
+
|
|
380
|
+
def usage(self) -> Usage:
|
|
381
|
+
"""Current credit usage for the authenticated key."""
|
|
382
|
+
return Usage.model_validate(self._request("GET", "/v1/billing/usage"))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ── Async client ─────────────────────────────────────────────
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
class AsyncStructX(_BaseClient):
|
|
389
|
+
"""Asynchronous counterpart of `StructX`. Same surface, same
|
|
390
|
+
typed responses, same exception classes — only the I/O is async.
|
|
391
|
+
|
|
392
|
+
Example:
|
|
393
|
+
>>> from structx_sdk import AsyncStructX
|
|
394
|
+
>>> async with AsyncStructX(api_key="sx_...") as client:
|
|
395
|
+
... result = await client.extract(content="...", schema={...})
|
|
396
|
+
"""
|
|
397
|
+
|
|
398
|
+
def __init__(
|
|
399
|
+
self,
|
|
400
|
+
api_key: str | None = None,
|
|
401
|
+
*,
|
|
402
|
+
base_url: str | None = None,
|
|
403
|
+
timeout: float = 30.0,
|
|
404
|
+
retry: RetryPolicy | None = None,
|
|
405
|
+
default_headers: Mapping[str, str] | None = None,
|
|
406
|
+
_http: httpx.AsyncClient | None = None,
|
|
407
|
+
) -> None:
|
|
408
|
+
super().__init__(
|
|
409
|
+
api_key=api_key or os.environ.get("STRUCTX_API_KEY", ""),
|
|
410
|
+
base_url=base_url or os.environ.get("STRUCTX_BASE_URL", _DEFAULT_BASE_URL),
|
|
411
|
+
timeout=timeout,
|
|
412
|
+
retry=retry or RetryPolicy(),
|
|
413
|
+
default_headers=default_headers or {},
|
|
414
|
+
)
|
|
415
|
+
self._http = _http or httpx.AsyncClient(timeout=timeout)
|
|
416
|
+
self._owns_http = _http is None
|
|
417
|
+
|
|
418
|
+
@classmethod
|
|
419
|
+
def from_env(cls, **overrides: Any) -> "AsyncStructX":
|
|
420
|
+
return cls(**overrides)
|
|
421
|
+
|
|
422
|
+
async def __aenter__(self) -> "AsyncStructX":
|
|
423
|
+
return self
|
|
424
|
+
|
|
425
|
+
async def __aexit__(self, *exc: Any) -> None:
|
|
426
|
+
await self.aclose()
|
|
427
|
+
|
|
428
|
+
async def aclose(self) -> None:
|
|
429
|
+
if self._owns_http:
|
|
430
|
+
await self._http.aclose()
|
|
431
|
+
|
|
432
|
+
async def _request(
|
|
433
|
+
self,
|
|
434
|
+
method: str,
|
|
435
|
+
path: str,
|
|
436
|
+
*,
|
|
437
|
+
json: Any = None,
|
|
438
|
+
params: Mapping[str, Any] | None = None,
|
|
439
|
+
headers: Mapping[str, str] | None = None,
|
|
440
|
+
) -> dict[str, Any]:
|
|
441
|
+
last_exc: Exception | None = None
|
|
442
|
+
for attempt in range(1, self.retry.max_attempts + 1):
|
|
443
|
+
try:
|
|
444
|
+
response = await self._http.request(
|
|
445
|
+
method=method,
|
|
446
|
+
url=self._url(path),
|
|
447
|
+
headers=self._headers(headers),
|
|
448
|
+
json=json,
|
|
449
|
+
params=params,
|
|
450
|
+
)
|
|
451
|
+
except (httpx.TransportError, httpx.TimeoutException) as e:
|
|
452
|
+
last_exc = TransportError(f"{type(e).__name__}: {e}")
|
|
453
|
+
if attempt < self.retry.max_attempts:
|
|
454
|
+
await asyncio.sleep(self.retry.backoff_for(attempt))
|
|
455
|
+
continue
|
|
456
|
+
raise last_exc from e
|
|
457
|
+
|
|
458
|
+
if attempt < self.retry.max_attempts and self._is_retryable_response(
|
|
459
|
+
path, response.status_code
|
|
460
|
+
):
|
|
461
|
+
retry_after = response.headers.get("retry-after")
|
|
462
|
+
try:
|
|
463
|
+
ra = float(retry_after) if retry_after else None
|
|
464
|
+
except ValueError:
|
|
465
|
+
ra = None
|
|
466
|
+
await asyncio.sleep(self.retry.backoff_for(attempt, retry_after=ra))
|
|
467
|
+
continue
|
|
468
|
+
|
|
469
|
+
return self._parse_response(response)
|
|
470
|
+
|
|
471
|
+
raise last_exc or StructXError("retry loop exhausted with no response")
|
|
472
|
+
|
|
473
|
+
async def extract(
|
|
474
|
+
self,
|
|
475
|
+
content: str,
|
|
476
|
+
*,
|
|
477
|
+
schema: dict[str, Any] | None = None,
|
|
478
|
+
template_slug: str | None = None,
|
|
479
|
+
tier: str = "required",
|
|
480
|
+
options: dict[str, Any] | None = None,
|
|
481
|
+
) -> Extraction:
|
|
482
|
+
body: dict[str, Any] = {"content": content, "tier": tier}
|
|
483
|
+
if schema is not None:
|
|
484
|
+
body["schema"] = schema
|
|
485
|
+
if template_slug is not None:
|
|
486
|
+
body["template_slug"] = template_slug
|
|
487
|
+
if options is not None:
|
|
488
|
+
body["options"] = options
|
|
489
|
+
return Extraction.model_validate(
|
|
490
|
+
await self._request("POST", "/v1/extract", json=body)
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
async def infer_schema(
|
|
494
|
+
self,
|
|
495
|
+
content: str,
|
|
496
|
+
*,
|
|
497
|
+
content_type: str | None = None,
|
|
498
|
+
hints: dict[str, Any] | None = None,
|
|
499
|
+
k: int = 5,
|
|
500
|
+
return_recommendations: bool = True,
|
|
501
|
+
) -> InferenceResult:
|
|
502
|
+
body: dict[str, Any] = {
|
|
503
|
+
"content": content,
|
|
504
|
+
"k": k,
|
|
505
|
+
"return_recommendations": return_recommendations,
|
|
506
|
+
}
|
|
507
|
+
if content_type is not None:
|
|
508
|
+
body["content_type"] = content_type
|
|
509
|
+
if hints is not None:
|
|
510
|
+
body["hints"] = hints
|
|
511
|
+
return InferenceResult.model_validate(
|
|
512
|
+
await self._request("POST", "/v1/schemas/infer", json=body)
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
async def list_templates(self) -> list[Template]:
|
|
516
|
+
raw = await self._request("GET", "/v1/schemas")
|
|
517
|
+
items = raw if isinstance(raw, list) else raw.get("templates", [])
|
|
518
|
+
return [Template.model_validate(t) for t in items]
|
|
519
|
+
|
|
520
|
+
async def list_models(self) -> list[Model]:
|
|
521
|
+
raw = await self._request("GET", "/v1/models")
|
|
522
|
+
items = raw if isinstance(raw, list) else raw.get("models", [])
|
|
523
|
+
return [Model.model_validate(m) for m in items]
|
|
524
|
+
|
|
525
|
+
async def usage(self) -> Usage:
|
|
526
|
+
return Usage.model_validate(await self._request("GET", "/v1/billing/usage"))
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Typed exception hierarchy for the struct-x SDK.
|
|
2
|
+
|
|
3
|
+
Every backend error response carries `{"error": str, "code": str}` (see
|
|
4
|
+
backend/routers/*.py — convention). The SDK maps `code` (or, if absent,
|
|
5
|
+
HTTP status) to a typed exception class so callers can `except
|
|
6
|
+
RateLimitError` instead of inspecting status codes.
|
|
7
|
+
|
|
8
|
+
Hierarchy is intentionally narrow — three categories that map to three
|
|
9
|
+
call-site decisions:
|
|
10
|
+
|
|
11
|
+
StructXError base; matches anything from the SDK
|
|
12
|
+
ApiError server returned an error response
|
|
13
|
+
AuthenticationError 401 — fix your API key
|
|
14
|
+
PermissionDeniedError 403 — your key works but lacks scope
|
|
15
|
+
RateLimitError 429 — back off; carries credits info
|
|
16
|
+
ValidationError 400/422 — fix your input
|
|
17
|
+
NotFoundError 404 — wrong slug or id
|
|
18
|
+
ServerError 5xx — retry or contact support
|
|
19
|
+
TransportError network/timeout failure, never reached the server
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class StructXError(Exception):
|
|
27
|
+
"""Base exception for all SDK errors."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TransportError(StructXError):
|
|
31
|
+
"""Network-level failure — the request never reached the API or the
|
|
32
|
+
response never made it back. Always safe to retry idempotent calls;
|
|
33
|
+
write calls (extract/infer) are *probably* safe but the backend may
|
|
34
|
+
have partially processed before the connection dropped."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Keys whose VALUES are stripped from `response_body` before the body
|
|
38
|
+
# is attached to the exception. Common sense + the platform's known
|
|
39
|
+
# field names. Customer code that logs `repr(exc)` or pipes
|
|
40
|
+
# exceptions to Sentry won't leak: API keys (`x-api-key`, `authorization`,
|
|
41
|
+
# `apikey`), session credentials (`password`, `token`, `secret`), or
|
|
42
|
+
# raw payload content (`content` — extraction inputs are often the
|
|
43
|
+
# most sensitive thing in the request). Match is case-insensitive.
|
|
44
|
+
_REDACTED_KEYS = frozenset({
|
|
45
|
+
"x-api-key", "authorization", "apikey", "api_key",
|
|
46
|
+
"password", "token", "secret", "private_key",
|
|
47
|
+
"content",
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
# Sentinel returned in place of the redacted value. The original
|
|
51
|
+
# payload size is preserved (in chars) so debugging can see "this
|
|
52
|
+
# field had data" without seeing what.
|
|
53
|
+
def _redact(value: object) -> str:
|
|
54
|
+
if isinstance(value, str):
|
|
55
|
+
return f"<redacted {len(value)} chars>"
|
|
56
|
+
if isinstance(value, (bytes, bytearray)):
|
|
57
|
+
return f"<redacted {len(value)} bytes>"
|
|
58
|
+
return "<redacted>"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _redact_body(body: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
62
|
+
"""Return a shallow copy of `body` with sensitive keys' values
|
|
63
|
+
replaced by a length-bounded redacted sentinel. Defends against
|
|
64
|
+
customer `repr(exc)` / Sentry captures of payloads that may
|
|
65
|
+
contain credentials or PII. Keys checked case-insensitively against
|
|
66
|
+
`_REDACTED_KEYS`."""
|
|
67
|
+
if not body:
|
|
68
|
+
return body
|
|
69
|
+
redacted: dict[str, Any] = {}
|
|
70
|
+
for k, v in body.items():
|
|
71
|
+
if isinstance(k, str) and k.lower() in _REDACTED_KEYS:
|
|
72
|
+
redacted[k] = _redact(v)
|
|
73
|
+
else:
|
|
74
|
+
redacted[k] = v
|
|
75
|
+
return redacted
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class ApiError(StructXError):
|
|
79
|
+
"""Server responded with an error. Subclasses below are status/code-
|
|
80
|
+
typed; if none matches, raw `ApiError` is raised so callers can still
|
|
81
|
+
pattern-match `except ApiError`.
|
|
82
|
+
|
|
83
|
+
Privacy posture (Phase 5.5 / rho): `response_body` has sensitive
|
|
84
|
+
keys redacted at exception-construction time — see `_REDACTED_KEYS`.
|
|
85
|
+
Customer code that logs `repr(exc)` or routes exceptions to Sentry
|
|
86
|
+
will see `<redacted N chars>` instead of credentials / payload
|
|
87
|
+
content. `__repr__` ALSO omits the body entirely as a second layer
|
|
88
|
+
— pass `.response_body` explicitly if you need the (redacted)
|
|
89
|
+
dict for programmatic inspection.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(
|
|
93
|
+
self,
|
|
94
|
+
message: str,
|
|
95
|
+
*,
|
|
96
|
+
status_code: int,
|
|
97
|
+
code: str | None = None,
|
|
98
|
+
response_body: dict[str, Any] | None = None,
|
|
99
|
+
request_id: str | None = None,
|
|
100
|
+
) -> None:
|
|
101
|
+
super().__init__(message)
|
|
102
|
+
self.message = message
|
|
103
|
+
self.status_code = status_code
|
|
104
|
+
self.code = code
|
|
105
|
+
# Redact at construction time so any post-creation access
|
|
106
|
+
# (including third-party traceback formatters) sees the safe
|
|
107
|
+
# version, not the raw payload.
|
|
108
|
+
self.response_body = _redact_body(response_body)
|
|
109
|
+
self.request_id = request_id
|
|
110
|
+
|
|
111
|
+
def __repr__(self) -> str:
|
|
112
|
+
# Intentionally does NOT include response_body — second layer
|
|
113
|
+
# of defense against `f"{exc!r}"` patterns in customer logs.
|
|
114
|
+
# The body is still accessible via `.response_body` for
|
|
115
|
+
# callers that need programmatic inspection.
|
|
116
|
+
bits = [f"status={self.status_code}"]
|
|
117
|
+
if self.code:
|
|
118
|
+
bits.append(f"code={self.code!r}")
|
|
119
|
+
if self.request_id:
|
|
120
|
+
bits.append(f"request_id={self.request_id!r}")
|
|
121
|
+
return f"{self.__class__.__name__}({self.message!r}, {', '.join(bits)})"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class AuthenticationError(ApiError):
|
|
125
|
+
"""401 — the API key is missing, malformed, or revoked."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class PermissionDeniedError(ApiError):
|
|
129
|
+
"""403 — the API key is valid but doesn't have access to this
|
|
130
|
+
resource (e.g. admin-only endpoint)."""
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class NotFoundError(ApiError):
|
|
134
|
+
"""404 — template slug, key id, or other resource doesn't exist."""
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class ValidationError(ApiError):
|
|
138
|
+
"""400 / 422 — your request payload is malformed. Common cases:
|
|
139
|
+
schema isn't a JSON object, content is too large, template_slug
|
|
140
|
+
doesn't resolve."""
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RateLimitError(ApiError):
|
|
144
|
+
"""429 — you've hit the daily credit cap OR the per-window request
|
|
145
|
+
cap. The `retry_after` attribute (seconds) is populated from the
|
|
146
|
+
`Retry-After` response header when present. Credits are populated
|
|
147
|
+
from the response body when the backend supplied them (it does for
|
|
148
|
+
credit-exhaustion 429s; not for IP-based throttling)."""
|
|
149
|
+
|
|
150
|
+
def __init__(
|
|
151
|
+
self,
|
|
152
|
+
message: str,
|
|
153
|
+
*,
|
|
154
|
+
status_code: int = 429,
|
|
155
|
+
code: str | None = None,
|
|
156
|
+
response_body: dict[str, Any] | None = None,
|
|
157
|
+
request_id: str | None = None,
|
|
158
|
+
retry_after: float | None = None,
|
|
159
|
+
credits_used: int | None = None,
|
|
160
|
+
credits_remaining: int | None = None,
|
|
161
|
+
) -> None:
|
|
162
|
+
super().__init__(
|
|
163
|
+
message,
|
|
164
|
+
status_code=status_code,
|
|
165
|
+
code=code,
|
|
166
|
+
response_body=response_body,
|
|
167
|
+
request_id=request_id,
|
|
168
|
+
)
|
|
169
|
+
self.retry_after = retry_after
|
|
170
|
+
self.credits_used = credits_used
|
|
171
|
+
self.credits_remaining = credits_remaining
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ServerError(ApiError):
|
|
175
|
+
"""5xx — the backend returned a server-side failure. Retrying may
|
|
176
|
+
help for 502/503/504; 500 usually means a bug — report it with the
|
|
177
|
+
`request_id` attribute attached."""
|
structx_sdk/_models.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Pydantic response models — mirror backend/models/schemas.py response
|
|
2
|
+
shapes but with SDK-idiomatic names. The backend's `ExtractionResponse`
|
|
3
|
+
becomes `Extraction` here; the SDK doesn't need the "Response" suffix
|
|
4
|
+
because the caller isn't dealing with HTTP-layer concerns.
|
|
5
|
+
|
|
6
|
+
Stays in lockstep with the backend by accepting EXTRA fields silently
|
|
7
|
+
(`model_config.extra = 'allow'`) — when the backend adds a new field,
|
|
8
|
+
old SDK versions don't break, they just don't surface it as a typed
|
|
9
|
+
attribute. Callers can still reach it via `obj.model_dump()` or
|
|
10
|
+
`obj.__pydantic_extra__`.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _Base(BaseModel):
|
|
21
|
+
model_config = ConfigDict(extra="allow", populate_by_name=True)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ── Extraction ───────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FieldConfidence(_Base):
|
|
28
|
+
"""Per-field confidence score returned by `/v1/extract`. The backend
|
|
29
|
+
populates `source_snippet` only when `include_citations=true` was
|
|
30
|
+
requested; otherwise it's None."""
|
|
31
|
+
|
|
32
|
+
field: str
|
|
33
|
+
confidence: float
|
|
34
|
+
source_snippet: str | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class TokenCounts(_Base):
|
|
38
|
+
input: int
|
|
39
|
+
output: int
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class Extraction(_Base):
|
|
43
|
+
"""The result of a single `/v1/extract` call."""
|
|
44
|
+
|
|
45
|
+
data: dict[str, Any] | list[Any]
|
|
46
|
+
model_used: str
|
|
47
|
+
latency_ms: int
|
|
48
|
+
tokens: TokenCounts
|
|
49
|
+
credits_used: int
|
|
50
|
+
credits_remaining: int | None = None
|
|
51
|
+
confidence: float | None = None
|
|
52
|
+
field_confidences: list[FieldConfidence] = Field(default_factory=list)
|
|
53
|
+
cached: bool = False
|
|
54
|
+
extraction_id: str | None = None
|
|
55
|
+
warnings: list[str] = Field(default_factory=list)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── Schema inference (recommender) ───────────────────────────
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class InferredField(_Base):
|
|
62
|
+
name: str
|
|
63
|
+
type: str
|
|
64
|
+
description: str
|
|
65
|
+
required: bool
|
|
66
|
+
rationale: str
|
|
67
|
+
confidence: float
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class InferredSchema(_Base):
|
|
71
|
+
"""The inferred JSON Schema plus per-field rationale. Returned by
|
|
72
|
+
`/v1/schemas/infer`. `json_schema` is a valid JSON Schema dict the
|
|
73
|
+
caller can immediately pass back to `extract()`."""
|
|
74
|
+
|
|
75
|
+
json_schema: dict[str, Any]
|
|
76
|
+
fields: list[InferredField] = Field(default_factory=list)
|
|
77
|
+
overall_confidence: float
|
|
78
|
+
needs_review: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Recommendation(_Base):
|
|
82
|
+
"""A template or user-schema candidate returned by the recommender."""
|
|
83
|
+
|
|
84
|
+
source_type: Literal["template", "user_schema", "inferred_draft"]
|
|
85
|
+
source_id: str
|
|
86
|
+
slug: str
|
|
87
|
+
name: str
|
|
88
|
+
score: float
|
|
89
|
+
json_schema: dict[str, Any]
|
|
90
|
+
score_breakdown: dict[str, float] = Field(default_factory=dict)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class InferenceResult(_Base):
|
|
94
|
+
"""The full payload from `/v1/schemas/infer` — the inferred schema,
|
|
95
|
+
optional template recommendations, and the `event_id` you'd echo
|
|
96
|
+
back to the feedback endpoint."""
|
|
97
|
+
|
|
98
|
+
event_id: str
|
|
99
|
+
inferred: InferredSchema
|
|
100
|
+
recommendations: list[Recommendation] = Field(default_factory=list)
|
|
101
|
+
credits_used: int
|
|
102
|
+
partial: bool = False
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ── Templates ────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Template(_Base):
|
|
109
|
+
"""A row from the public template gallery (`/v1/schemas`).
|
|
110
|
+
|
|
111
|
+
Note: the JSON Schema field is named `schema_def` on the Python
|
|
112
|
+
object (with alias `"schema"` for the wire format). Pydantic v2
|
|
113
|
+
reserves `.schema` as a model-introspection method, so we can't
|
|
114
|
+
use the bare name — same convention the backend uses in
|
|
115
|
+
`backend/models/schemas.py:ExtractionRequest`.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
slug: str
|
|
119
|
+
name: str
|
|
120
|
+
description: str | None = None
|
|
121
|
+
category: str | None = None
|
|
122
|
+
schema_def: dict[str, Any] = Field(default_factory=dict, alias="schema")
|
|
123
|
+
usage_count: int | None = None
|
|
124
|
+
acceptance_rate: float | None = None
|
|
125
|
+
avg_confidence: float | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ── Models / routing info ────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Model(_Base):
|
|
132
|
+
"""A model entry from `/v1/models`. The SDK exposes these for
|
|
133
|
+
introspection; callers normally don't pick a specific model and let
|
|
134
|
+
the backend's router decide."""
|
|
135
|
+
|
|
136
|
+
name: str
|
|
137
|
+
provider: str
|
|
138
|
+
capability: str | None = None
|
|
139
|
+
notes: str | None = None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── Usage ────────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class Usage(_Base):
|
|
146
|
+
"""Summary returned by `/v1/billing/usage` (or wherever the auth'd
|
|
147
|
+
`/auth/me`-style usage call lives in the deployment). Field set
|
|
148
|
+
intentionally minimal; extra backend fields surface via `extra=allow`."""
|
|
149
|
+
|
|
150
|
+
credits_used_today: int
|
|
151
|
+
credits_limit_daily: int
|
|
152
|
+
tier: str | None = None
|
|
153
|
+
period_start: datetime | None = None
|
|
154
|
+
period_end: datetime | None = None
|
structx_sdk/_version.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
structx_sdk/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: structx-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Official Python SDK (structx-sdk) for struct-x — agent-native structured extraction.
|
|
5
|
+
Project-URL: Homepage, https://structx.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.structx.ai
|
|
7
|
+
Project-URL: Repository, https://github.com/struct-x-ai/struct-x
|
|
8
|
+
Project-URL: Issues, https://github.com/struct-x-ai/struct-x/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/struct-x-ai/struct-x/blob/main/sdk/python/CHANGELOG.md
|
|
10
|
+
Author-email: struct-x <support@structx.ai>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: agent,ai,extraction,json-schema,llm,mcp,structured-extraction
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.10
|
|
27
|
+
Requires-Dist: httpx<0.29,>=0.27
|
|
28
|
+
Requires-Dist: pydantic<3.0,>=2.5
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: mypy>=1.8; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
33
|
+
Requires-Dist: respx>=0.21; extra == 'dev'
|
|
34
|
+
Requires-Dist: ruff>=0.5; extra == 'dev'
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
|
|
37
|
+
# structx-sdk — Python SDK for struct-x
|
|
38
|
+
|
|
39
|
+
[](https://pypi.org/project/structx-sdk/)
|
|
40
|
+
[](https://pypi.org/project/structx-sdk/)
|
|
41
|
+
[](LICENSE)
|
|
42
|
+
|
|
43
|
+
Official Python client for **[struct-x](https://structx.ai)** — the agent-native structured-extraction API. Send raw content and a JSON Schema, get back validated, typed JSON with per-field confidence scores.
|
|
44
|
+
|
|
45
|
+
## Install
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
pip install structx-sdk
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Quickstart
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from structx_sdk import StructX
|
|
55
|
+
|
|
56
|
+
client = StructX(api_key="sx_...")
|
|
57
|
+
|
|
58
|
+
result = client.extract(
|
|
59
|
+
content="<div><h1>Aeron Chair</h1><span>$1,795.00</span></div>",
|
|
60
|
+
schema={
|
|
61
|
+
"type": "object",
|
|
62
|
+
"required": ["title", "price_cents"],
|
|
63
|
+
"properties": {
|
|
64
|
+
"title": {"type": "string"},
|
|
65
|
+
"price_cents": {"type": "integer"},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
print(result.data)
|
|
71
|
+
# {'title': 'Aeron Chair', 'price_cents': 179500}
|
|
72
|
+
|
|
73
|
+
print(result.field_confidences[0])
|
|
74
|
+
# FieldConfidence(field='title', confidence=0.96, source_snippet=None)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Use a catalog template instead of an inline schema
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
result = client.extract(
|
|
81
|
+
content=stripe_webhook_payload,
|
|
82
|
+
template_slug="logs.stripe.event", # latest published version
|
|
83
|
+
)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Pin to a specific template version with `family_slug@version`:
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
template_slug="logs.stripe.event@1.0.0"
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Don't have a schema yet? Let the API infer one
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
inference = client.infer_schema(
|
|
96
|
+
content="<html>… some product page …</html>",
|
|
97
|
+
content_type="html",
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
print(inference.inferred.json_schema) # ready to pass back to extract()
|
|
101
|
+
for f in inference.inferred.fields:
|
|
102
|
+
print(f"{f.name} ({f.type}) — {f.rationale}")
|
|
103
|
+
|
|
104
|
+
# Plus template recommendations, if any matched:
|
|
105
|
+
for r in inference.recommendations:
|
|
106
|
+
print(f"{r.slug} (score={r.score:.2f})")
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Async
|
|
110
|
+
|
|
111
|
+
Same surface, `await`-flavored:
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
import asyncio
|
|
115
|
+
from structx_sdk import AsyncStructX
|
|
116
|
+
|
|
117
|
+
async def main():
|
|
118
|
+
async with AsyncStructX(api_key="sx_...") as client:
|
|
119
|
+
result = await client.extract(content="…", schema={…})
|
|
120
|
+
print(result.data)
|
|
121
|
+
|
|
122
|
+
asyncio.run(main())
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Configuration
|
|
126
|
+
|
|
127
|
+
| Param | Default | Notes |
|
|
128
|
+
|----------------|-------------------------------|--------------------------------------------------------|
|
|
129
|
+
| `api_key` | `STRUCTX_API_KEY` env var | Required. |
|
|
130
|
+
| `base_url` | `STRUCTX_BASE_URL` env var, else `https://api.structx.ai` | Override for staging / self-hosted. |
|
|
131
|
+
| `timeout` | `30.0` seconds | Applied per request. |
|
|
132
|
+
| `retry` | `RetryPolicy(max_attempts=3, …)` | Tune via `RetryPolicy(...)`. |
|
|
133
|
+
| `default_headers` | `{}` | Merged into every request — e.g., for tracing IDs. |
|
|
134
|
+
|
|
135
|
+
Pick up credentials from the environment with `StructX.from_env()`:
|
|
136
|
+
|
|
137
|
+
```python
|
|
138
|
+
import os
|
|
139
|
+
os.environ["STRUCTX_API_KEY"] = "sx_..."
|
|
140
|
+
|
|
141
|
+
from structx_sdk import StructX
|
|
142
|
+
client = StructX.from_env()
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
## Errors
|
|
146
|
+
|
|
147
|
+
All exceptions inherit from `StructXError`. Catch the specific class you care about:
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from structx_sdk import RateLimitError, ValidationError, ServerError
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
result = client.extract(content=…, schema=…)
|
|
154
|
+
except RateLimitError as e:
|
|
155
|
+
# 429 — back off; e.retry_after, e.credits_used, e.credits_remaining are populated
|
|
156
|
+
print(f"Sleep {e.retry_after}s. Used {e.credits_used}/{e.credits_used + e.credits_remaining}.")
|
|
157
|
+
except ValidationError as e:
|
|
158
|
+
# 400/422 — fix your input. e.code carries the machine-readable reason.
|
|
159
|
+
print(f"Bad input: {e.code} — {e.message}")
|
|
160
|
+
except ServerError as e:
|
|
161
|
+
# 5xx — retry or contact support; e.request_id is your handle.
|
|
162
|
+
print(f"Server error (request_id={e.request_id})")
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Full hierarchy:
|
|
166
|
+
|
|
167
|
+
```
|
|
168
|
+
StructXError
|
|
169
|
+
├── TransportError # network failure — request never reached the server
|
|
170
|
+
└── ApiError # server responded with an error status
|
|
171
|
+
├── AuthenticationError # 401
|
|
172
|
+
├── PermissionDeniedError # 403
|
|
173
|
+
├── NotFoundError # 404
|
|
174
|
+
├── ValidationError # 400, 422
|
|
175
|
+
├── RateLimitError # 429 (carries retry_after, credits info)
|
|
176
|
+
└── ServerError # 5xx
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
## Retries
|
|
180
|
+
|
|
181
|
+
By default, **read** calls (`list_templates`, `list_models`, `usage`) auto-retry on transient 5xx and connection errors with exponential backoff.
|
|
182
|
+
|
|
183
|
+
**Write** calls (`extract`, `infer_schema`) retry ONLY on transport errors, never on 5xx — because a 5xx after a partial backend run may have already billed the call.
|
|
184
|
+
|
|
185
|
+
Customize via `RetryPolicy`:
|
|
186
|
+
|
|
187
|
+
```python
|
|
188
|
+
from structx_sdk import StructX, RetryPolicy
|
|
189
|
+
|
|
190
|
+
client = StructX(
|
|
191
|
+
api_key="sx_...",
|
|
192
|
+
retry=RetryPolicy(
|
|
193
|
+
max_attempts=5,
|
|
194
|
+
initial_backoff=0.5,
|
|
195
|
+
max_backoff=60.0,
|
|
196
|
+
retry_on_5xx=True,
|
|
197
|
+
respect_retry_after=True,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## Forward compatibility
|
|
203
|
+
|
|
204
|
+
Response models accept extra fields silently. When the API adds a new field, old SDK versions don't break — they just don't surface it as a typed attribute. Reach it via `result.model_dump()` or `result.__pydantic_extra__`.
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
git clone https://github.com/struct-x-ai/struct-x
|
|
210
|
+
cd struct-x/sdk/python
|
|
211
|
+
pip install -e ".[dev]"
|
|
212
|
+
pytest -q
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
## License
|
|
216
|
+
|
|
217
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
structx_sdk/__init__.py,sha256=p5wf3CtJwDoR3wDImDgJetoq60MBIC1p_NN_D6fXLqM,1836
|
|
2
|
+
structx_sdk/_client.py,sha256=XXcSo5OAs2nA6EufbdQKQzOPMdRMCJdDPvExCV43BBs,18807
|
|
3
|
+
structx_sdk/_exceptions.py,sha256=1hjA4XjmIBsn8wY4tWSXju7oYWaSokLcp9Y32-XkZiI,6875
|
|
4
|
+
structx_sdk/_models.py,sha256=etnuPXTXtqwz888CwLtBLUNLbkEPQJ-PTBpoOo7W64w,5059
|
|
5
|
+
structx_sdk/_version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
|
|
6
|
+
structx_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
structx_sdk-0.2.0.dist-info/METADATA,sha256=2LDCzFuypWzSyOPYZq6DSgE2npMJy-Kzn0FgMRSDDzg,7201
|
|
8
|
+
structx_sdk-0.2.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
9
|
+
structx_sdk-0.2.0.dist-info/licenses/LICENSE,sha256=sbvXdvix1vIUS5hCXHHwpeBylS2pLxG343RiI9zMU1E,1065
|
|
10
|
+
structx_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 struct-x
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|