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/__init__.py +137 -0
- arex/_poll.py +126 -0
- arex/_transport.py +303 -0
- arex/_validate.py +156 -0
- arex/_wire.py +126 -0
- arex/async_client.py +133 -0
- arex/client.py +197 -0
- arex/errors.py +334 -0
- arex/models.py +414 -0
- arex/py.typed +0 -0
- arex/research.py +363 -0
- arex_python_sdk-0.2.0.dist-info/METADATA +55 -0
- arex_python_sdk-0.2.0.dist-info/RECORD +15 -0
- arex_python_sdk-0.2.0.dist-info/WHEEL +4 -0
- arex_python_sdk-0.2.0.dist-info/licenses/LICENSE +203 -0
arex/_validate.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Validate requests before sending them using generated wire constraints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import Any, get_args
|
|
7
|
+
from urllib.parse import urlsplit
|
|
8
|
+
from uuid import uuid4
|
|
9
|
+
|
|
10
|
+
from arex._wire import WIRE_LIMITS, BrowseTier, ResearchModeRequest
|
|
11
|
+
from arex.errors import InvalidRequestError
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"MAX_TIMEOUT_MS",
|
|
15
|
+
"is_timeout_ms",
|
|
16
|
+
"resolve_idempotency_key",
|
|
17
|
+
"validate_browse",
|
|
18
|
+
"validate_research_create",
|
|
19
|
+
"validate_research_wait",
|
|
20
|
+
"validate_run_id",
|
|
21
|
+
"validate_search",
|
|
22
|
+
"validate_timeout_ms",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_RUN_ID_PATTERN = re.compile(
|
|
26
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}"
|
|
27
|
+
r"-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _integer(field: str, value: object, bounds: dict[str, Any]) -> None:
|
|
32
|
+
if not isinstance(value, int) or isinstance(value, bool):
|
|
33
|
+
raise InvalidRequestError(f"{field} must be an integer.", param=field)
|
|
34
|
+
if not bounds["minimum"] <= value <= bounds["maximum"]:
|
|
35
|
+
raise InvalidRequestError(
|
|
36
|
+
f"{field} must be between {bounds['minimum']} and {bounds['maximum']}.",
|
|
37
|
+
param=field,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _query(value: str, bounds: dict[str, Any]) -> None:
|
|
42
|
+
if not isinstance(value, str) or not value.strip():
|
|
43
|
+
raise InvalidRequestError("query must be a non-empty string.", param="query")
|
|
44
|
+
# Python string lengths count Unicode code points rather than UTF-16 units.
|
|
45
|
+
if not bounds["minLength"] <= len(value) <= bounds["maxLength"]:
|
|
46
|
+
raise InvalidRequestError(
|
|
47
|
+
f"query must be at most {bounds['maxLength']} characters.", param="query"
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def validate_search(query: str, limit: int | None) -> None:
|
|
52
|
+
bounds = WIRE_LIMITS["SearchRequest"]
|
|
53
|
+
_query(query, bounds["query"])
|
|
54
|
+
if limit is not None:
|
|
55
|
+
_integer("limit", limit, bounds["limit"])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def validate_browse(
|
|
59
|
+
url: str,
|
|
60
|
+
*,
|
|
61
|
+
tier: str | None,
|
|
62
|
+
livecrawl: bool | None,
|
|
63
|
+
livecrawl_timeout_ms: int | None,
|
|
64
|
+
max_characters: int | None,
|
|
65
|
+
) -> None:
|
|
66
|
+
bounds = WIRE_LIMITS["BrowseRequest"]
|
|
67
|
+
if not isinstance(url, str) or not url.strip():
|
|
68
|
+
raise InvalidRequestError("url must be a non-empty string.", param="url")
|
|
69
|
+
if len(url) > bounds["url"]["maxLength"]:
|
|
70
|
+
raise InvalidRequestError(
|
|
71
|
+
f"url must be at most {bounds['url']['maxLength']} characters.", param="url"
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
parts = urlsplit(url)
|
|
75
|
+
valid_host = parts.hostname
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
raise InvalidRequestError(
|
|
78
|
+
"url must be an absolute HTTP(S) URL.", param="url"
|
|
79
|
+
) from exc
|
|
80
|
+
if parts.scheme not in ("http", "https") or not valid_host:
|
|
81
|
+
raise InvalidRequestError("url must be an absolute HTTP(S) URL.", param="url")
|
|
82
|
+
if parts.username is not None or parts.password is not None:
|
|
83
|
+
raise InvalidRequestError("url must not contain credentials.", param="url")
|
|
84
|
+
if tier is not None and tier not in get_args(BrowseTier):
|
|
85
|
+
raise InvalidRequestError("tier must be auto or flex.", param="tier")
|
|
86
|
+
if livecrawl is not None and not isinstance(livecrawl, bool):
|
|
87
|
+
raise InvalidRequestError("livecrawl must be a boolean.", param="livecrawl")
|
|
88
|
+
if livecrawl_timeout_ms is not None:
|
|
89
|
+
if livecrawl is not True:
|
|
90
|
+
raise InvalidRequestError(
|
|
91
|
+
"livecrawl_timeout_ms is only allowed with livecrawl=True.",
|
|
92
|
+
param="livecrawl_timeout_ms",
|
|
93
|
+
)
|
|
94
|
+
_integer(
|
|
95
|
+
"livecrawl_timeout_ms", livecrawl_timeout_ms, bounds["livecrawl_timeout_ms"]
|
|
96
|
+
)
|
|
97
|
+
if max_characters is not None:
|
|
98
|
+
_integer("max_characters", max_characters, bounds["max_characters"])
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def validate_research_create(query: str, mode: str | None) -> None:
|
|
102
|
+
_query(query, WIRE_LIMITS["ResearchCreateRequest"]["query"])
|
|
103
|
+
if mode is not None and mode not in get_args(ResearchModeRequest):
|
|
104
|
+
raise InvalidRequestError("mode must be fast, deep or auto.", param="mode")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validate_run_id(run_id: str) -> None:
|
|
108
|
+
if not isinstance(run_id, str) or not _RUN_ID_PATTERN.fullmatch(run_id):
|
|
109
|
+
raise InvalidRequestError("run_id must be a UUID.", param="run_id")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def validate_research_wait(poll_interval_ms: int, timeout_ms: int | None) -> None:
|
|
113
|
+
"""Runs before `create` in `run`, so a bad poll setting never pays for a run
|
|
114
|
+
it cannot wait for."""
|
|
115
|
+
if not is_timeout_ms(poll_interval_ms):
|
|
116
|
+
raise InvalidRequestError(
|
|
117
|
+
f"poll_interval_ms must be an integer between 1 and {MAX_TIMEOUT_MS}.",
|
|
118
|
+
param="poll_interval_ms",
|
|
119
|
+
)
|
|
120
|
+
if timeout_ms is not None:
|
|
121
|
+
validate_timeout_ms(timeout_ms)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
# The largest delay a JavaScript timer accepts; also keeps the millisecond to
|
|
125
|
+
# second conversion and `socket` timeouts far from float overflow.
|
|
126
|
+
MAX_TIMEOUT_MS = 2**31 - 1
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_timeout_ms(value: object) -> bool:
|
|
130
|
+
return (
|
|
131
|
+
isinstance(value, int)
|
|
132
|
+
and not isinstance(value, bool)
|
|
133
|
+
and 0 < value <= MAX_TIMEOUT_MS
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def validate_timeout_ms(value: object) -> None:
|
|
138
|
+
if not is_timeout_ms(value):
|
|
139
|
+
raise InvalidRequestError(
|
|
140
|
+
f"timeout_ms must be a positive integer of at most {MAX_TIMEOUT_MS}.",
|
|
141
|
+
param="timeout_ms",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_idempotency_key(key: str | None) -> str:
|
|
146
|
+
"""The caller's key once validated, else a fresh UUID4 for this one call."""
|
|
147
|
+
if key is None:
|
|
148
|
+
return str(uuid4())
|
|
149
|
+
pattern = WIRE_LIMITS["IdempotencyKey"]["pattern"]
|
|
150
|
+
# `fullmatch` so the pattern's `$` cannot accept a trailing newline.
|
|
151
|
+
if not isinstance(key, str) or not re.fullmatch(pattern, key):
|
|
152
|
+
raise InvalidRequestError(
|
|
153
|
+
"idempotency_key must be 1 to 255 characters from A-Z a-z 0-9 . _ -",
|
|
154
|
+
param="idempotency_key",
|
|
155
|
+
)
|
|
156
|
+
return key
|
arex/_wire.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# Generated from openapi/arex-v1.json by generate_wire.py; do not edit.
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from typing_extensions import Required
|
|
8
|
+
|
|
9
|
+
ErrorCode = Literal['invalid_request', 'content_blocked', 'unauthorized', 'registration_required', 'account_deletion_pending', 'feature_not_enabled', 'not_found', 'method_not_allowed', 'run_not_cancellable', 'idempotency_key_in_progress', 'source_not_available', 'idempotency_key_reused', 'rate_limit_exceeded', 'outstanding_run_limit', 'daily_credits_exhausted', 'weekly_credits_exhausted', 'internal_error', 'temporarily_unavailable']
|
|
10
|
+
ResearchFailureCode = Literal['timeout', 'execution_failed', 'worker_unavailable', 'interrupted']
|
|
11
|
+
BrowseTier = Literal['auto', 'flex']
|
|
12
|
+
ResearchMode = Literal['fast', 'deep']
|
|
13
|
+
ResearchModeRequest = Literal['fast', 'deep', 'auto']
|
|
14
|
+
ResearchStatus = Literal['queued', 'running', 'completed', 'failed', 'cancelled']
|
|
15
|
+
|
|
16
|
+
class BrowseRequest(TypedDict, total=False):
|
|
17
|
+
url: Required[str]
|
|
18
|
+
tier: BrowseTier
|
|
19
|
+
livecrawl: bool
|
|
20
|
+
livecrawl_timeout_ms: int
|
|
21
|
+
max_characters: int
|
|
22
|
+
|
|
23
|
+
class BrowseResponse(TypedDict, total=False):
|
|
24
|
+
url: Required[str]
|
|
25
|
+
title: str
|
|
26
|
+
published_at: str
|
|
27
|
+
image_url: str
|
|
28
|
+
image_urls: list[str]
|
|
29
|
+
content: Required[str]
|
|
30
|
+
truncated: Required[bool]
|
|
31
|
+
|
|
32
|
+
class ErrorError(TypedDict, total=False):
|
|
33
|
+
code: Required[ErrorCode]
|
|
34
|
+
message: Required[str]
|
|
35
|
+
retryable: Required[bool]
|
|
36
|
+
request_id: Required[str]
|
|
37
|
+
param: str
|
|
38
|
+
resource: str
|
|
39
|
+
used: int
|
|
40
|
+
limit: int
|
|
41
|
+
requested: int
|
|
42
|
+
reset_at: str
|
|
43
|
+
|
|
44
|
+
class Error(TypedDict, total=False):
|
|
45
|
+
error: Required[ErrorError]
|
|
46
|
+
|
|
47
|
+
class ResearchCreateRequest(TypedDict, total=False):
|
|
48
|
+
query: Required[str]
|
|
49
|
+
mode: ResearchModeRequest
|
|
50
|
+
|
|
51
|
+
class ResearchRunResultSourcesItem(TypedDict, total=False):
|
|
52
|
+
title: Required[str]
|
|
53
|
+
url: Required[str]
|
|
54
|
+
|
|
55
|
+
class ResearchRunResult(TypedDict, total=False):
|
|
56
|
+
answer: Required[str]
|
|
57
|
+
sources: Required[list[ResearchRunResultSourcesItem]]
|
|
58
|
+
|
|
59
|
+
class ResearchRunError(TypedDict, total=False):
|
|
60
|
+
code: Required[ResearchFailureCode]
|
|
61
|
+
retryable: Required[bool]
|
|
62
|
+
|
|
63
|
+
class ResearchRun(TypedDict, total=False):
|
|
64
|
+
id: Required[str]
|
|
65
|
+
status: Required[ResearchStatus]
|
|
66
|
+
mode: Required[ResearchMode]
|
|
67
|
+
requested_mode: Required[ResearchModeRequest]
|
|
68
|
+
created_at: Required[str]
|
|
69
|
+
started_at: str
|
|
70
|
+
completed_at: str
|
|
71
|
+
result: ResearchRunResult
|
|
72
|
+
error: ResearchRunError
|
|
73
|
+
|
|
74
|
+
class SearchRequest(TypedDict, total=False):
|
|
75
|
+
query: Required[str]
|
|
76
|
+
limit: int
|
|
77
|
+
|
|
78
|
+
class SearchResponseResultsItem(TypedDict, total=False):
|
|
79
|
+
title: Required[str]
|
|
80
|
+
url: Required[str]
|
|
81
|
+
snippet: str
|
|
82
|
+
|
|
83
|
+
class SearchResponse(TypedDict, total=False):
|
|
84
|
+
results: Required[list[SearchResponseResultsItem]]
|
|
85
|
+
|
|
86
|
+
class UsageResponseWeek(TypedDict, total=False):
|
|
87
|
+
start: Required[str]
|
|
88
|
+
used: Required[int]
|
|
89
|
+
limit: int
|
|
90
|
+
remaining: int
|
|
91
|
+
reset_at: Required[str]
|
|
92
|
+
|
|
93
|
+
class UsageResponse(TypedDict, total=False):
|
|
94
|
+
date: Required[str]
|
|
95
|
+
used: Required[int]
|
|
96
|
+
limit: int
|
|
97
|
+
remaining: int
|
|
98
|
+
reset_at: Required[str]
|
|
99
|
+
week: UsageResponseWeek
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
WIRE_LIMITS: dict[str, Any] = {'BrowseRequest': {'livecrawl': {'default': False},
|
|
103
|
+
'livecrawl_timeout_ms': {'maximum': 90000, 'minimum': 1000},
|
|
104
|
+
'max_characters': {'default': 12000,
|
|
105
|
+
'maximum': 131072,
|
|
106
|
+
'minimum': 1},
|
|
107
|
+
'tier': {'default': 'auto', 'enum': ['auto', 'flex']},
|
|
108
|
+
'url': {'maxLength': 8192,
|
|
109
|
+
'pattern': '^[Hh][Tt][Tt][Pp][Ss]?://[^/@?#]+(?:[/?#]|$)'}},
|
|
110
|
+
'BrowseResponse': {'image_urls': {'maxItems': 50}},
|
|
111
|
+
'IdempotencyKey': {'pattern': '^[A-Za-z0-9._-]{1,255}$', 'type': 'string'},
|
|
112
|
+
'ResearchCreateRequest': {'mode': {'default': 'auto',
|
|
113
|
+
'enum': ['fast', 'deep', 'auto']},
|
|
114
|
+
'query': {'maxLength': 2000, 'minLength': 1}},
|
|
115
|
+
'ResearchRun': {'mode': {'enum': ['fast', 'deep']},
|
|
116
|
+
'requested_mode': {'enum': ['fast', 'deep', 'auto']},
|
|
117
|
+
'status': {'enum': ['queued',
|
|
118
|
+
'running',
|
|
119
|
+
'completed',
|
|
120
|
+
'failed',
|
|
121
|
+
'cancelled']}},
|
|
122
|
+
'SearchRequest': {'limit': {'default': 10, 'maximum': 10, 'minimum': 1},
|
|
123
|
+
'query': {'maxLength': 2000, 'minLength': 1}},
|
|
124
|
+
'UsageResponse': {'limit': {'minimum': 0},
|
|
125
|
+
'remaining': {'minimum': 0},
|
|
126
|
+
'used': {'minimum': 0}}}
|
arex/async_client.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""The asynchronous `AsyncArex` client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from types import TracebackType
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from arex._transport import AsyncTransport
|
|
11
|
+
from arex._validate import resolve_idempotency_key, validate_browse, validate_search
|
|
12
|
+
from arex.client import (
|
|
13
|
+
browse_body,
|
|
14
|
+
browse_timeout_ms,
|
|
15
|
+
require_credits,
|
|
16
|
+
resolve_api_key,
|
|
17
|
+
)
|
|
18
|
+
from arex.models import (
|
|
19
|
+
DEFAULT_BASE_URL,
|
|
20
|
+
DEFAULT_MAX_RETRIES,
|
|
21
|
+
SEARCH_TIMEOUT_MS,
|
|
22
|
+
USAGE_TIMEOUT_MS,
|
|
23
|
+
BrowseDocument,
|
|
24
|
+
BrowseTier,
|
|
25
|
+
CreditUsage,
|
|
26
|
+
SearchResponse,
|
|
27
|
+
)
|
|
28
|
+
from arex.research import AsyncResearch
|
|
29
|
+
|
|
30
|
+
__all__ = ["AsyncArex"]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class AsyncArex:
|
|
34
|
+
"""Asynchronous client for the AREX public API."""
|
|
35
|
+
|
|
36
|
+
def __init__(
|
|
37
|
+
self,
|
|
38
|
+
api_key: str | None = None,
|
|
39
|
+
*,
|
|
40
|
+
base_url: str | None = None,
|
|
41
|
+
timeout_ms: int | None = None,
|
|
42
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
43
|
+
retry_unavailable: bool = False,
|
|
44
|
+
http_client: httpx.AsyncClient | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._transport = AsyncTransport(
|
|
47
|
+
api_key=resolve_api_key(api_key),
|
|
48
|
+
base_url=base_url or DEFAULT_BASE_URL,
|
|
49
|
+
timeout_ms=timeout_ms,
|
|
50
|
+
max_retries=max_retries,
|
|
51
|
+
retry_unavailable=retry_unavailable,
|
|
52
|
+
client=http_client,
|
|
53
|
+
)
|
|
54
|
+
self.research = AsyncResearch(self._transport)
|
|
55
|
+
|
|
56
|
+
async def search(
|
|
57
|
+
self,
|
|
58
|
+
query: str,
|
|
59
|
+
*,
|
|
60
|
+
limit: int | None = None,
|
|
61
|
+
idempotency_key: str | None = None,
|
|
62
|
+
timeout_ms: int | None = None,
|
|
63
|
+
) -> SearchResponse:
|
|
64
|
+
"""Run a web search."""
|
|
65
|
+
validate_search(query, limit)
|
|
66
|
+
key = resolve_idempotency_key(idempotency_key)
|
|
67
|
+
body: dict[str, Any] = {"query": query}
|
|
68
|
+
if limit is not None:
|
|
69
|
+
body["limit"] = limit
|
|
70
|
+
result = await self._transport.request(
|
|
71
|
+
"POST",
|
|
72
|
+
"/v1/search",
|
|
73
|
+
json=body,
|
|
74
|
+
idempotency_key=key,
|
|
75
|
+
timeout_ms=self._transport.resolve_timeout(SEARCH_TIMEOUT_MS, timeout_ms),
|
|
76
|
+
)
|
|
77
|
+
return SearchResponse.from_wire(result.data, require_credits(result.credits))
|
|
78
|
+
|
|
79
|
+
async def browse(
|
|
80
|
+
self,
|
|
81
|
+
url: str,
|
|
82
|
+
*,
|
|
83
|
+
tier: BrowseTier | None = None,
|
|
84
|
+
livecrawl: bool | None = None,
|
|
85
|
+
livecrawl_timeout_ms: int | None = None,
|
|
86
|
+
max_characters: int | None = None,
|
|
87
|
+
idempotency_key: str | None = None,
|
|
88
|
+
timeout_ms: int | None = None,
|
|
89
|
+
) -> BrowseDocument:
|
|
90
|
+
"""Fetch and extract one page."""
|
|
91
|
+
validate_browse(
|
|
92
|
+
url,
|
|
93
|
+
tier=tier,
|
|
94
|
+
livecrawl=livecrawl,
|
|
95
|
+
livecrawl_timeout_ms=livecrawl_timeout_ms,
|
|
96
|
+
max_characters=max_characters,
|
|
97
|
+
)
|
|
98
|
+
key = resolve_idempotency_key(idempotency_key)
|
|
99
|
+
default_ms = browse_timeout_ms(tier, livecrawl, livecrawl_timeout_ms)
|
|
100
|
+
result = await self._transport.request(
|
|
101
|
+
"POST",
|
|
102
|
+
"/v1/browse",
|
|
103
|
+
idempotency_key=key,
|
|
104
|
+
json=browse_body(
|
|
105
|
+
url, tier, livecrawl, livecrawl_timeout_ms, max_characters
|
|
106
|
+
),
|
|
107
|
+
timeout_ms=self._transport.resolve_timeout(default_ms, timeout_ms),
|
|
108
|
+
)
|
|
109
|
+
return BrowseDocument.from_wire(result.data, require_credits(result.credits))
|
|
110
|
+
|
|
111
|
+
async def usage(self, *, timeout_ms: int | None = None) -> CreditUsage:
|
|
112
|
+
"""Read the current credit windows."""
|
|
113
|
+
result = await self._transport.request(
|
|
114
|
+
"GET",
|
|
115
|
+
"/v1/usage",
|
|
116
|
+
timeout_ms=self._transport.resolve_timeout(USAGE_TIMEOUT_MS, timeout_ms),
|
|
117
|
+
)
|
|
118
|
+
return CreditUsage.from_wire(result.data)
|
|
119
|
+
|
|
120
|
+
async def aclose(self) -> None:
|
|
121
|
+
"""Close the HTTP client, unless it was injected."""
|
|
122
|
+
await self._transport.aclose()
|
|
123
|
+
|
|
124
|
+
async def __aenter__(self) -> AsyncArex:
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(
|
|
128
|
+
self,
|
|
129
|
+
exc_type: type[BaseException] | None,
|
|
130
|
+
exc: BaseException | None,
|
|
131
|
+
tb: TracebackType | None,
|
|
132
|
+
) -> None:
|
|
133
|
+
await self.aclose()
|
arex/client.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""The synchronous `Arex` client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from types import TracebackType
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import httpx
|
|
10
|
+
|
|
11
|
+
from arex._transport import Transport
|
|
12
|
+
from arex._validate import resolve_idempotency_key, validate_browse, validate_search
|
|
13
|
+
from arex.errors import ArexNetworkError
|
|
14
|
+
from arex.models import (
|
|
15
|
+
BROWSE_AUTO_TIMEOUT_MS,
|
|
16
|
+
BROWSE_FLEX_TIMEOUT_MS,
|
|
17
|
+
DEFAULT_BASE_URL,
|
|
18
|
+
DEFAULT_BROWSE_TIER,
|
|
19
|
+
DEFAULT_LIVECRAWL_TIMEOUT_AUTO_MS,
|
|
20
|
+
DEFAULT_LIVECRAWL_TIMEOUT_FLEX_MS,
|
|
21
|
+
DEFAULT_MAX_RETRIES,
|
|
22
|
+
LIVECRAWL_TIMEOUT_MARGIN_MS,
|
|
23
|
+
SEARCH_TIMEOUT_MS,
|
|
24
|
+
USAGE_TIMEOUT_MS,
|
|
25
|
+
BrowseDocument,
|
|
26
|
+
BrowseTier,
|
|
27
|
+
Credits,
|
|
28
|
+
CreditUsage,
|
|
29
|
+
SearchResponse,
|
|
30
|
+
)
|
|
31
|
+
from arex.research import Research
|
|
32
|
+
|
|
33
|
+
__all__ = ["Arex"]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def resolve_api_key(api_key: str | None) -> str:
|
|
37
|
+
"""The explicit key, else `AREX_API_KEY`."""
|
|
38
|
+
key = api_key if api_key is not None else os.environ.get("AREX_API_KEY")
|
|
39
|
+
if not key:
|
|
40
|
+
raise ValueError(
|
|
41
|
+
"An AREX API key is required: pass api_key= or set AREX_API_KEY."
|
|
42
|
+
)
|
|
43
|
+
return key
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def browse_body(
|
|
47
|
+
url: str,
|
|
48
|
+
tier: BrowseTier | None,
|
|
49
|
+
livecrawl: bool | None,
|
|
50
|
+
livecrawl_timeout_ms: int | None,
|
|
51
|
+
max_characters: int | None,
|
|
52
|
+
) -> dict[str, Any]:
|
|
53
|
+
body: dict[str, Any] = {"url": url}
|
|
54
|
+
if tier is not None:
|
|
55
|
+
body["tier"] = tier
|
|
56
|
+
if livecrawl is not None:
|
|
57
|
+
body["livecrawl"] = livecrawl
|
|
58
|
+
if livecrawl_timeout_ms is not None:
|
|
59
|
+
body["livecrawl_timeout_ms"] = livecrawl_timeout_ms
|
|
60
|
+
if max_characters is not None:
|
|
61
|
+
body["max_characters"] = max_characters
|
|
62
|
+
return body
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def browse_timeout_ms(
|
|
66
|
+
tier: BrowseTier | None,
|
|
67
|
+
livecrawl: bool | None,
|
|
68
|
+
livecrawl_timeout_ms: int | None,
|
|
69
|
+
) -> int:
|
|
70
|
+
"""The server deadline that applies to this browse call, plus the margin."""
|
|
71
|
+
effective_tier: BrowseTier = tier if tier is not None else DEFAULT_BROWSE_TIER
|
|
72
|
+
if livecrawl:
|
|
73
|
+
server_deadline = (
|
|
74
|
+
livecrawl_timeout_ms
|
|
75
|
+
if livecrawl_timeout_ms is not None
|
|
76
|
+
else (
|
|
77
|
+
DEFAULT_LIVECRAWL_TIMEOUT_AUTO_MS
|
|
78
|
+
if effective_tier == "auto"
|
|
79
|
+
else DEFAULT_LIVECRAWL_TIMEOUT_FLEX_MS
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
return server_deadline + LIVECRAWL_TIMEOUT_MARGIN_MS
|
|
83
|
+
return (
|
|
84
|
+
BROWSE_AUTO_TIMEOUT_MS if effective_tier == "auto" else BROWSE_FLEX_TIMEOUT_MS
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def require_credits(credits: Credits | None) -> Credits:
|
|
89
|
+
if credits is None:
|
|
90
|
+
raise ArexNetworkError(
|
|
91
|
+
"invalid_response",
|
|
92
|
+
"The AREX API response is missing the X-Arex-Credits-Used header.",
|
|
93
|
+
)
|
|
94
|
+
return credits
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class Arex:
|
|
98
|
+
"""Synchronous client for the AREX public API."""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
api_key: str | None = None,
|
|
103
|
+
*,
|
|
104
|
+
base_url: str | None = None,
|
|
105
|
+
timeout_ms: int | None = None,
|
|
106
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
107
|
+
retry_unavailable: bool = False,
|
|
108
|
+
http_client: httpx.Client | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
self._transport = Transport(
|
|
111
|
+
api_key=resolve_api_key(api_key),
|
|
112
|
+
base_url=base_url or DEFAULT_BASE_URL,
|
|
113
|
+
timeout_ms=timeout_ms,
|
|
114
|
+
max_retries=max_retries,
|
|
115
|
+
retry_unavailable=retry_unavailable,
|
|
116
|
+
client=http_client,
|
|
117
|
+
)
|
|
118
|
+
self.research = Research(self._transport)
|
|
119
|
+
|
|
120
|
+
def search(
|
|
121
|
+
self,
|
|
122
|
+
query: str,
|
|
123
|
+
*,
|
|
124
|
+
limit: int | None = None,
|
|
125
|
+
idempotency_key: str | None = None,
|
|
126
|
+
timeout_ms: int | None = None,
|
|
127
|
+
) -> SearchResponse:
|
|
128
|
+
"""Run a web search."""
|
|
129
|
+
validate_search(query, limit)
|
|
130
|
+
key = resolve_idempotency_key(idempotency_key)
|
|
131
|
+
body: dict[str, Any] = {"query": query}
|
|
132
|
+
if limit is not None:
|
|
133
|
+
body["limit"] = limit
|
|
134
|
+
result = self._transport.request(
|
|
135
|
+
"POST",
|
|
136
|
+
"/v1/search",
|
|
137
|
+
json=body,
|
|
138
|
+
idempotency_key=key,
|
|
139
|
+
timeout_ms=self._transport.resolve_timeout(SEARCH_TIMEOUT_MS, timeout_ms),
|
|
140
|
+
)
|
|
141
|
+
return SearchResponse.from_wire(result.data, require_credits(result.credits))
|
|
142
|
+
|
|
143
|
+
def browse(
|
|
144
|
+
self,
|
|
145
|
+
url: str,
|
|
146
|
+
*,
|
|
147
|
+
tier: BrowseTier | None = None,
|
|
148
|
+
livecrawl: bool | None = None,
|
|
149
|
+
livecrawl_timeout_ms: int | None = None,
|
|
150
|
+
max_characters: int | None = None,
|
|
151
|
+
idempotency_key: str | None = None,
|
|
152
|
+
timeout_ms: int | None = None,
|
|
153
|
+
) -> BrowseDocument:
|
|
154
|
+
"""Fetch and extract one page."""
|
|
155
|
+
validate_browse(
|
|
156
|
+
url,
|
|
157
|
+
tier=tier,
|
|
158
|
+
livecrawl=livecrawl,
|
|
159
|
+
livecrawl_timeout_ms=livecrawl_timeout_ms,
|
|
160
|
+
max_characters=max_characters,
|
|
161
|
+
)
|
|
162
|
+
key = resolve_idempotency_key(idempotency_key)
|
|
163
|
+
default_ms = browse_timeout_ms(tier, livecrawl, livecrawl_timeout_ms)
|
|
164
|
+
result = self._transport.request(
|
|
165
|
+
"POST",
|
|
166
|
+
"/v1/browse",
|
|
167
|
+
idempotency_key=key,
|
|
168
|
+
json=browse_body(
|
|
169
|
+
url, tier, livecrawl, livecrawl_timeout_ms, max_characters
|
|
170
|
+
),
|
|
171
|
+
timeout_ms=self._transport.resolve_timeout(default_ms, timeout_ms),
|
|
172
|
+
)
|
|
173
|
+
return BrowseDocument.from_wire(result.data, require_credits(result.credits))
|
|
174
|
+
|
|
175
|
+
def usage(self, *, timeout_ms: int | None = None) -> CreditUsage:
|
|
176
|
+
"""Read the current credit windows."""
|
|
177
|
+
result = self._transport.request(
|
|
178
|
+
"GET",
|
|
179
|
+
"/v1/usage",
|
|
180
|
+
timeout_ms=self._transport.resolve_timeout(USAGE_TIMEOUT_MS, timeout_ms),
|
|
181
|
+
)
|
|
182
|
+
return CreditUsage.from_wire(result.data)
|
|
183
|
+
|
|
184
|
+
def close(self) -> None:
|
|
185
|
+
"""Close the HTTP client, unless it was injected."""
|
|
186
|
+
self._transport.close()
|
|
187
|
+
|
|
188
|
+
def __enter__(self) -> Arex:
|
|
189
|
+
return self
|
|
190
|
+
|
|
191
|
+
def __exit__(
|
|
192
|
+
self,
|
|
193
|
+
exc_type: type[BaseException] | None,
|
|
194
|
+
exc: BaseException | None,
|
|
195
|
+
tb: TracebackType | None,
|
|
196
|
+
) -> None:
|
|
197
|
+
self.close()
|