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/_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()