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.
@@ -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
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/structx-sdk.svg)](https://pypi.org/project/structx-sdk/)
40
+ [![Python](https://img.shields.io/pypi/pyversions/structx-sdk.svg)](https://pypi.org/project/structx-sdk/)
41
+ [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.