arex-python-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.
arex/models.py ADDED
@@ -0,0 +1,414 @@
1
+ """Public result models and SDK-owned defaults."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass
8
+ from typing import Any, get_args
9
+
10
+ from arex import _wire
11
+ from arex._wire import (
12
+ WIRE_LIMITS,
13
+ BrowseTier,
14
+ ResearchFailureCode,
15
+ ResearchMode,
16
+ ResearchModeRequest,
17
+ ResearchStatus,
18
+ )
19
+
20
+ __all__ = [
21
+ "API_KEY_PATTERN",
22
+ "API_KEY_PREFIX",
23
+ "BROWSE_AUTO_TIMEOUT_MS",
24
+ "BROWSE_FLEX_TIMEOUT_MS",
25
+ "BROWSE_TIERS",
26
+ "DEEP_POLL_TIMEOUT_MS",
27
+ "DEFAULT_BASE_URL",
28
+ "DEFAULT_BROWSE_MAX_CHARACTERS",
29
+ "DEFAULT_BROWSE_TIER",
30
+ "DEFAULT_LIVECRAWL_TIMEOUT_AUTO_MS",
31
+ "DEFAULT_LIVECRAWL_TIMEOUT_FLEX_MS",
32
+ "DEFAULT_MAX_RETRIES",
33
+ "DEFAULT_POLL_INTERVAL_MS",
34
+ "DEFAULT_RESEARCH_MODE",
35
+ "DEFAULT_RETRY_DELAY_MS",
36
+ "DEFAULT_SEARCH_LIMIT",
37
+ "FAST_POLL_TIMEOUT_MS",
38
+ "LIVECRAWL_TIMEOUT_MARGIN_MS",
39
+ "RESEARCH_MODES",
40
+ "RESEARCH_REQUEST_TIMEOUT_MS",
41
+ "SEARCH_TIMEOUT_MS",
42
+ "USAGE_TIMEOUT_MS",
43
+ "BrowseDocument",
44
+ "BrowseTier",
45
+ "CreditUsage",
46
+ "CreditWeek",
47
+ "CreditWindow",
48
+ "Credits",
49
+ "ResearchFailureCode",
50
+ "ResearchMode",
51
+ "ResearchModeRequest",
52
+ "ResearchResult",
53
+ "ResearchRun",
54
+ "ResearchRunError",
55
+ "ResearchSource",
56
+ "ResearchStatus",
57
+ "SearchResponse",
58
+ "SearchResult",
59
+ "is_arex_api_key",
60
+ ]
61
+
62
+
63
+ # Authentication
64
+ API_KEY_PREFIX = "sk_arex_"
65
+ API_KEY_PATTERN = re.compile(r"^sk_arex_[A-Za-z0-9_-]{32}$")
66
+ DEFAULT_BASE_URL = "https://api.arex-research.com"
67
+
68
+ DEFAULT_SEARCH_LIMIT = WIRE_LIMITS["SearchRequest"]["limit"]["default"]
69
+ BROWSE_TIERS = get_args(BrowseTier)
70
+ DEFAULT_BROWSE_TIER: BrowseTier = WIRE_LIMITS["BrowseRequest"]["tier"]["default"]
71
+ DEFAULT_LIVECRAWL_TIMEOUT_AUTO_MS = 20000
72
+ DEFAULT_LIVECRAWL_TIMEOUT_FLEX_MS = 90000
73
+ DEFAULT_BROWSE_MAX_CHARACTERS = WIRE_LIMITS["BrowseRequest"]["max_characters"][
74
+ "default"
75
+ ]
76
+ RESEARCH_MODES = get_args(ResearchModeRequest)
77
+ DEFAULT_RESEARCH_MODE: ResearchModeRequest = WIRE_LIMITS["ResearchCreateRequest"][
78
+ "mode"
79
+ ]["default"]
80
+
81
+ # SDK-owned timeout, polling and retry defaults
82
+ SEARCH_TIMEOUT_MS = 30000
83
+ USAGE_TIMEOUT_MS = 15000
84
+ BROWSE_AUTO_TIMEOUT_MS = 30000
85
+ BROWSE_FLEX_TIMEOUT_MS = 70000
86
+ LIVECRAWL_TIMEOUT_MARGIN_MS = 10000
87
+ RESEARCH_REQUEST_TIMEOUT_MS = 30000
88
+ DEFAULT_POLL_INTERVAL_MS = 2000
89
+ FAST_POLL_TIMEOUT_MS = 150000
90
+ DEEP_POLL_TIMEOUT_MS = 180000
91
+ DEFAULT_MAX_RETRIES = 2
92
+ DEFAULT_RETRY_DELAY_MS = 1000
93
+
94
+
95
+ def is_arex_api_key(value: str) -> bool:
96
+ """Whether `value` has the shape of an AREX API key."""
97
+ return API_KEY_PATTERN.match(value) is not None
98
+
99
+
100
+ def _invalid(field: str) -> Exception:
101
+ from arex.errors import ArexNetworkError
102
+
103
+ return ArexNetworkError(
104
+ "invalid_response",
105
+ f"The AREX API response is missing or mistyped the field {field!r}.",
106
+ )
107
+
108
+
109
+ def _object(data: Any, field: str) -> Mapping[str, Any]:
110
+ if not isinstance(data, Mapping):
111
+ raise _invalid(field)
112
+ return data
113
+
114
+
115
+ def _str(data: Mapping[str, Any], key: str) -> str:
116
+ value = data.get(key)
117
+ if not isinstance(value, str):
118
+ raise _invalid(key)
119
+ return value
120
+
121
+
122
+ def _opt_str(data: Mapping[str, Any], key: str) -> str | None:
123
+ value = data.get(key)
124
+ if value is None:
125
+ return None
126
+ if not isinstance(value, str):
127
+ raise _invalid(key)
128
+ return value
129
+
130
+
131
+ def _int(data: Mapping[str, Any], key: str) -> int:
132
+ value = data.get(key)
133
+ if not isinstance(value, int) or isinstance(value, bool):
134
+ raise _invalid(key)
135
+ return value
136
+
137
+
138
+ def _nullable_int(data: Mapping[str, Any], key: str) -> int | None:
139
+ value = data.get(key)
140
+ if value is None:
141
+ return None
142
+ if not isinstance(value, int) or isinstance(value, bool):
143
+ raise _invalid(key)
144
+ return value
145
+
146
+
147
+ def _bool(data: Mapping[str, Any], key: str) -> bool:
148
+ value = data.get(key)
149
+ if not isinstance(value, bool):
150
+ raise _invalid(key)
151
+ return value
152
+
153
+
154
+ def _literal(data: Mapping[str, Any], key: str, allowed: tuple[str, ...]) -> str:
155
+ value = data.get(key)
156
+ if not isinstance(value, str) or value not in allowed:
157
+ raise _invalid(key)
158
+ return value
159
+
160
+
161
+ @dataclass(frozen=True, slots=True)
162
+ class Credits:
163
+ """Credit counters reported alongside a charged response."""
164
+
165
+ used: int
166
+ limit: int | None = None
167
+ remaining: int | None = None
168
+ reset_at: str | None = None
169
+
170
+ @classmethod
171
+ def from_headers(cls, headers: Mapping[str, str]) -> Credits | None:
172
+ """Read the `X-Arex-Credits-*` headers, or `None` when they are absent."""
173
+ lowered = {key.lower(): value for key, value in headers.items()}
174
+ used = _header_int(lowered, "x-arex-credits-used")
175
+ if used is None:
176
+ return None
177
+ return cls(
178
+ used=used,
179
+ limit=_header_int(lowered, "x-arex-credits-limit"),
180
+ remaining=_header_int(lowered, "x-arex-credits-remaining"),
181
+ reset_at=lowered.get("x-arex-credits-reset"),
182
+ )
183
+
184
+
185
+ def _header_int(headers: Mapping[str, str], name: str) -> int | None:
186
+ raw = headers.get(name)
187
+ if raw is None:
188
+ return None
189
+ try:
190
+ return int(raw.strip())
191
+ except ValueError:
192
+ return None
193
+
194
+
195
+ @dataclass(frozen=True, slots=True)
196
+ class SearchResult:
197
+ """One search hit."""
198
+
199
+ title: str
200
+ url: str
201
+ snippet: str | None = None
202
+
203
+ @classmethod
204
+ def from_wire(cls, data: _wire.SearchResponseResultsItem) -> SearchResult:
205
+ obj = _object(data, "results[]")
206
+ return cls(
207
+ title=_str(obj, "title"),
208
+ url=_str(obj, "url"),
209
+ snippet=_opt_str(obj, "snippet"),
210
+ )
211
+
212
+
213
+ @dataclass(frozen=True, slots=True)
214
+ class SearchResponse:
215
+ """The result of `Arex.search`."""
216
+
217
+ results: tuple[SearchResult, ...]
218
+ credits: Credits
219
+
220
+ @classmethod
221
+ def from_wire(cls, data: _wire.SearchResponse, credits: Credits) -> SearchResponse:
222
+ obj = _object(data, "body")
223
+ results = obj.get("results")
224
+ if not isinstance(results, list):
225
+ raise _invalid("results")
226
+ return cls(
227
+ results=tuple(SearchResult.from_wire(item) for item in results),
228
+ credits=credits,
229
+ )
230
+
231
+
232
+ @dataclass(frozen=True, slots=True)
233
+ class BrowseDocument:
234
+ """The result of `Arex.browse`."""
235
+
236
+ url: str
237
+ title: str | None
238
+ published_at: str | None
239
+ image_url: str | None
240
+ image_urls: tuple[str, ...] | None
241
+ content: str
242
+ truncated: bool
243
+ credits: Credits
244
+
245
+ @classmethod
246
+ def from_wire(cls, data: _wire.BrowseResponse, credits: Credits) -> BrowseDocument:
247
+ obj = _object(data, "body")
248
+ raw_urls = obj.get("image_urls")
249
+ if raw_urls is None:
250
+ image_urls = None
251
+ elif (
252
+ isinstance(raw_urls, list)
253
+ and len(raw_urls) <= WIRE_LIMITS["BrowseResponse"]["image_urls"]["maxItems"]
254
+ and all(isinstance(item, str) for item in raw_urls)
255
+ ):
256
+ image_urls = tuple(raw_urls)
257
+ else:
258
+ raise _invalid("image_urls")
259
+ return cls(
260
+ url=_str(obj, "url"),
261
+ title=_opt_str(obj, "title"),
262
+ published_at=_opt_str(obj, "published_at"),
263
+ image_url=_opt_str(obj, "image_url"),
264
+ image_urls=image_urls,
265
+ content=_str(obj, "content"),
266
+ truncated=_bool(obj, "truncated"),
267
+ credits=credits,
268
+ )
269
+
270
+
271
+ @dataclass(frozen=True, slots=True)
272
+ class CreditWindow:
273
+ """Credit consumption inside one accounting window."""
274
+
275
+ used: int
276
+ limit: int | None
277
+ remaining: int | None
278
+ reset_at: str
279
+
280
+
281
+ @dataclass(frozen=True, slots=True)
282
+ class CreditWeek(CreditWindow):
283
+ """The weekly credit window, which also reports when it started."""
284
+
285
+ start: str
286
+
287
+ @classmethod
288
+ def from_wire(cls, data: _wire.UsageResponseWeek) -> CreditWeek:
289
+ obj = _object(data, "week")
290
+ return cls(
291
+ used=_int(obj, "used"),
292
+ limit=_nullable_int(obj, "limit"),
293
+ remaining=_nullable_int(obj, "remaining"),
294
+ reset_at=_str(obj, "reset_at"),
295
+ start=_str(obj, "start"),
296
+ )
297
+
298
+
299
+ @dataclass(frozen=True, slots=True)
300
+ class CreditUsage:
301
+ """The result of `Arex.usage`: the daily window plus the weekly one."""
302
+
303
+ date: str
304
+ used: int
305
+ limit: int | None
306
+ remaining: int | None
307
+ reset_at: str
308
+ week: CreditWeek | None = None
309
+
310
+ @classmethod
311
+ def from_wire(cls, data: _wire.UsageResponse) -> CreditUsage:
312
+ obj = _object(data, "body")
313
+ week = obj.get("week")
314
+ return cls(
315
+ date=_str(obj, "date"),
316
+ used=_int(obj, "used"),
317
+ limit=_nullable_int(obj, "limit"),
318
+ remaining=_nullable_int(obj, "remaining"),
319
+ reset_at=_str(obj, "reset_at"),
320
+ week=None if week is None else CreditWeek.from_wire(week),
321
+ )
322
+
323
+
324
+ @dataclass(frozen=True, slots=True)
325
+ class ResearchSource:
326
+ """One source cited by a research answer."""
327
+
328
+ title: str
329
+ url: str
330
+
331
+ @classmethod
332
+ def from_wire(cls, data: _wire.ResearchRunResultSourcesItem) -> ResearchSource:
333
+ obj = _object(data, "result.sources[]")
334
+ return cls(title=_str(obj, "title"), url=_str(obj, "url"))
335
+
336
+
337
+ @dataclass(frozen=True, slots=True)
338
+ class ResearchResult:
339
+ """The answer of a completed research run."""
340
+
341
+ answer: str
342
+ sources: tuple[ResearchSource, ...]
343
+
344
+ @classmethod
345
+ def from_wire(cls, data: _wire.ResearchRunResult) -> ResearchResult:
346
+ obj = _object(data, "result")
347
+ sources = obj.get("sources")
348
+ if not isinstance(sources, list):
349
+ raise _invalid("result.sources")
350
+ return cls(
351
+ answer=_str(obj, "answer"),
352
+ sources=tuple(ResearchSource.from_wire(item) for item in sources),
353
+ )
354
+
355
+
356
+ @dataclass(frozen=True, slots=True)
357
+ class ResearchRunError:
358
+ """Why a research run failed."""
359
+
360
+ code: ResearchFailureCode
361
+ retryable: bool
362
+
363
+ @classmethod
364
+ def from_wire(cls, data: _wire.ResearchRunError) -> ResearchRunError:
365
+ obj = _object(data, "error")
366
+ return cls(
367
+ code=_literal(obj, "code", get_args(ResearchFailureCode)), # type: ignore[arg-type]
368
+ retryable=_bool(obj, "retryable"),
369
+ )
370
+
371
+
372
+ @dataclass(frozen=True, slots=True)
373
+ class ResearchRun:
374
+ """A research run at one point in its lifecycle."""
375
+
376
+ id: str
377
+ status: ResearchStatus
378
+ mode: ResearchMode
379
+ requested_mode: ResearchModeRequest
380
+ created_at: str
381
+ started_at: str | None = None
382
+ completed_at: str | None = None
383
+ result: ResearchResult | None = None
384
+ error: ResearchRunError | None = None
385
+ credits: Credits | None = None
386
+
387
+ @classmethod
388
+ def from_wire(
389
+ cls, data: _wire.ResearchRun, credits: Credits | None = None
390
+ ) -> ResearchRun:
391
+ obj = _object(data, "body")
392
+ status = _literal(obj, "status", get_args(ResearchStatus))
393
+ mode = _literal(obj, "mode", get_args(ResearchMode))
394
+ # Payloads are meaningful only in their matching terminal state.
395
+ result = obj.get("result") if status == "completed" else None
396
+ error = obj.get("error") if status == "failed" else None
397
+ if status == "completed" and result is None:
398
+ raise _invalid("result")
399
+ if status == "failed" and error is None:
400
+ raise _invalid("error")
401
+ return cls(
402
+ id=_str(obj, "id"),
403
+ status=status, # type: ignore[arg-type]
404
+ mode=mode, # type: ignore[arg-type]
405
+ requested_mode=_literal(
406
+ obj, "requested_mode", get_args(ResearchModeRequest)
407
+ ), # type: ignore[arg-type]
408
+ created_at=_str(obj, "created_at"),
409
+ started_at=_opt_str(obj, "started_at"),
410
+ completed_at=_opt_str(obj, "completed_at"),
411
+ result=None if result is None else ResearchResult.from_wire(result),
412
+ error=None if error is None else ResearchRunError.from_wire(error),
413
+ credits=credits,
414
+ )
arex/py.typed ADDED
File without changes