brime 0.1.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.
brime/__init__.py ADDED
@@ -0,0 +1,58 @@
1
+ """Brime — Official Python SDK."""
2
+
3
+ from brime._version import __version__
4
+ from brime.client import Brime
5
+ from brime.errors import (
6
+ AuthenticationError,
7
+ BrimeError,
8
+ InsufficientCreditsError,
9
+ InternalError,
10
+ InvalidRequestError,
11
+ NotFoundError,
12
+ RateLimitError,
13
+ UpstreamError,
14
+ )
15
+ from brime.models.extract import (
16
+ ExtractFailedItem,
17
+ ExtractMetadata,
18
+ ExtractResponse,
19
+ ExtractResultItem,
20
+ )
21
+ from brime.models.research import (
22
+ ResearchBasicResponse,
23
+ ResearchDeepInitResponse,
24
+ ResearchSseEvent,
25
+ ResearchStatusResponse,
26
+ )
27
+ from brime.models.search import SearchResponse, SearchResultItem
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "Brime",
32
+ "BrimeError",
33
+ "AuthenticationError",
34
+ "RateLimitError",
35
+ "InsufficientCreditsError",
36
+ "InvalidRequestError",
37
+ "NotFoundError",
38
+ "UpstreamError",
39
+ "InternalError",
40
+ "SearchResponse",
41
+ "SearchResultItem",
42
+ "ExtractResponse",
43
+ "ExtractResultItem",
44
+ "ExtractFailedItem",
45
+ "ExtractMetadata",
46
+ "ResearchBasicResponse",
47
+ "ResearchDeepInitResponse",
48
+ "ResearchStatusResponse",
49
+ "ResearchSseEvent",
50
+ ]
51
+
52
+
53
+ def __getattr__(name: str) -> object: # pragma: no cover
54
+ """Lazy AsyncBrime import (added in S6)."""
55
+ if name == "AsyncBrime":
56
+ from brime.async_client import AsyncBrime
57
+ return AsyncBrime
58
+ raise AttributeError(name)
brime/_http.py ADDED
@@ -0,0 +1,99 @@
1
+ """Internal HTTP layer shared by sync and async clients.
2
+
3
+ Responsibilities:
4
+ - Resolve api_key (arg → env BRIME_API_KEY)
5
+ - Resolve base_url (arg → env BRIME_BASE_URL → https://api.brime.dev)
6
+ - Build standard headers (Authorization, User-Agent, optional Idempotency-Key)
7
+ - Decode error responses into Brime exceptions
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import uuid
15
+ from typing import Any, Dict, Mapping, Optional
16
+
17
+ import httpx
18
+
19
+ from brime._version import __version__
20
+ from brime.errors import BrimeError, exception_from_response
21
+
22
+ DEFAULT_BASE_URL = "https://api.brime.dev"
23
+ DEFAULT_TIMEOUT_S = 30.0
24
+ DEEP_RESEARCH_TIMEOUT_S = 600.0
25
+ USER_AGENT = f"brime-python/{__version__}"
26
+
27
+
28
+ def resolve_api_key(api_key: Optional[str]) -> str:
29
+ if api_key:
30
+ return api_key
31
+ env = os.environ.get("BRIME_API_KEY")
32
+ if env:
33
+ return env
34
+ raise RuntimeError(
35
+ "Brime API key not set. Pass api_key=... or set BRIME_API_KEY env var."
36
+ )
37
+
38
+
39
+ def resolve_base_url(base_url: Optional[str]) -> str:
40
+ if base_url:
41
+ return base_url.rstrip("/")
42
+ env = os.environ.get("BRIME_BASE_URL")
43
+ if env:
44
+ return env.rstrip("/")
45
+ return DEFAULT_BASE_URL
46
+
47
+
48
+ def build_headers(
49
+ api_key: str,
50
+ *,
51
+ json_body: bool = True,
52
+ idempotency_key: Optional[str] = None,
53
+ accept: Optional[str] = None,
54
+ extra: Optional[Mapping[str, str]] = None,
55
+ ) -> Dict[str, str]:
56
+ headers: Dict[str, str] = {
57
+ "authorization": f"Bearer {api_key}",
58
+ "user-agent": USER_AGENT,
59
+ "accept": accept or "application/json",
60
+ }
61
+ if json_body:
62
+ headers["content-type"] = "application/json"
63
+ if idempotency_key:
64
+ headers["idempotency-key"] = idempotency_key
65
+ if extra:
66
+ for k, v in extra.items():
67
+ headers[k.lower()] = v
68
+ return headers
69
+
70
+
71
+ def new_idempotency_key() -> str:
72
+ return str(uuid.uuid4())
73
+
74
+
75
+ def decode_response(res: httpx.Response) -> Any:
76
+ """Parse JSON or raise the appropriate BrimeError on non-2xx."""
77
+ text = res.text
78
+ body: Any = None
79
+ if text:
80
+ try:
81
+ body = json.loads(text)
82
+ except json.JSONDecodeError:
83
+ body = None
84
+
85
+ request_id_header = res.headers.get("x-request-id")
86
+
87
+ if res.is_success:
88
+ return body
89
+
90
+ err_body = body if isinstance(body, dict) else None
91
+ raise exception_from_response(res.status_code, err_body, request_id_header)
92
+
93
+
94
+ def wrap_transport_error(exc: Exception) -> BrimeError:
95
+ """Convert httpx transport-level errors to a BrimeError shape."""
96
+ return exception_from_response(
97
+ 0,
98
+ {"error": {"code": "internal_error", "message": f"network error: {exc!s}"}},
99
+ )
brime/_polling.py ADDED
@@ -0,0 +1,72 @@
1
+ """Research deep-mode polling helpers.
2
+
3
+ `research(depth="deep", wait=True)` blocks until the job reaches a
4
+ terminal state (complete | errored | timeout) or `poll_timeout` elapses.
5
+
6
+ Design:
7
+ - Caller passes a status fetcher (sync or async closure)
8
+ - We delay between polls with optional jitter-free exponential backoff
9
+ capped at `max_interval`
10
+ - Terminal states stop polling; on poll_timeout we raise TimeoutError
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import time
17
+ from typing import Awaitable, Callable
18
+
19
+ from brime.models.research import ResearchStatusResponse
20
+
21
+ TERMINAL = ("complete", "errored", "timeout")
22
+
23
+
24
+ def _next_interval(prev: float, max_interval: float) -> float:
25
+ return min(prev * 1.5, max_interval)
26
+
27
+
28
+ def poll_until_terminal_sync(
29
+ fetch: Callable[[], ResearchStatusResponse],
30
+ *,
31
+ initial_interval: float,
32
+ max_interval: float,
33
+ poll_timeout: float,
34
+ ) -> ResearchStatusResponse:
35
+ deadline = time.monotonic() + poll_timeout
36
+ interval = initial_interval
37
+ while True:
38
+ status = fetch()
39
+ if status.status in TERMINAL:
40
+ return status
41
+ remaining = deadline - time.monotonic()
42
+ if remaining <= 0:
43
+ raise TimeoutError(
44
+ f"research polling exceeded {poll_timeout}s "
45
+ f"(last status: {status.status}, round {status.current_round}/{status.max_rounds})"
46
+ )
47
+ time.sleep(min(interval, remaining))
48
+ interval = _next_interval(interval, max_interval)
49
+
50
+
51
+ async def poll_until_terminal_async(
52
+ fetch: Callable[[], Awaitable[ResearchStatusResponse]],
53
+ *,
54
+ initial_interval: float,
55
+ max_interval: float,
56
+ poll_timeout: float,
57
+ ) -> ResearchStatusResponse:
58
+ loop = asyncio.get_event_loop()
59
+ deadline = loop.time() + poll_timeout
60
+ interval = initial_interval
61
+ while True:
62
+ status = await fetch()
63
+ if status.status in TERMINAL:
64
+ return status
65
+ remaining = deadline - loop.time()
66
+ if remaining <= 0:
67
+ raise TimeoutError(
68
+ f"research polling exceeded {poll_timeout}s "
69
+ f"(last status: {status.status}, round {status.current_round}/{status.max_rounds})"
70
+ )
71
+ await asyncio.sleep(min(interval, remaining))
72
+ interval = _next_interval(interval, max_interval)
brime/_sse.py ADDED
@@ -0,0 +1,131 @@
1
+ """Server-Sent Events parser.
2
+
3
+ Brime /v1/research stream emits frames like::
4
+
5
+ event: tool_call\\n
6
+ data: {"round": 1, "queries": ["..."]}\\n
7
+ \\n
8
+
9
+ This module turns httpx byte iterators into ResearchSseEvent dicts.
10
+ Handles fragmented chunks (a single SSE frame may arrive across multiple
11
+ read() calls) and the [DONE] terminator.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ from typing import AsyncIterator, Dict, Iterator, List, Optional
18
+
19
+
20
+ class _SseAccumulator:
21
+ """Stateful frame buffer.
22
+
23
+ Feed `feed(text)` repeatedly with raw decoded chunks. After each feed,
24
+ drain `pop_frames()` to collect complete SSE frames. A frame is delimited
25
+ by a blank line (\\n\\n).
26
+ """
27
+
28
+ __slots__ = ("_buf", "_done")
29
+
30
+ def __init__(self) -> None:
31
+ self._buf = ""
32
+ self._done = False
33
+
34
+ @property
35
+ def done(self) -> bool:
36
+ return self._done
37
+
38
+ def feed(self, chunk: str) -> None:
39
+ self._buf += chunk
40
+
41
+ def pop_frames(self) -> List[Dict[str, object]]:
42
+ out: List[Dict[str, object]] = []
43
+ while True:
44
+ idx = self._buf.find("\n\n")
45
+ if idx < 0:
46
+ break
47
+ frame = self._buf[:idx]
48
+ self._buf = self._buf[idx + 2 :]
49
+ evt = _parse_frame(frame)
50
+ if evt is None:
51
+ continue
52
+ if evt is _DONE_SENTINEL:
53
+ self._done = True
54
+ break
55
+ out.append(evt)
56
+ return out
57
+
58
+
59
+ _DONE_SENTINEL: Dict[str, object] = {"__done__": True}
60
+
61
+
62
+ def _parse_frame(frame: str) -> Optional[Dict[str, object]]:
63
+ """Convert a raw SSE frame text into a normalized event dict.
64
+
65
+ Returns None for empty/comment-only frames; returns _DONE_SENTINEL on
66
+ `data: [DONE]` lines (used by some adapters).
67
+ """
68
+ event_type: Optional[str] = None
69
+ event_id: Optional[str] = None
70
+ data_lines: List[str] = []
71
+ for raw_line in frame.split("\n"):
72
+ line = raw_line.rstrip("\r")
73
+ if not line or line.startswith(":"):
74
+ continue
75
+ if ":" in line:
76
+ field, _, value = line.partition(":")
77
+ value = value.lstrip(" ")
78
+ else:
79
+ field, value = line, ""
80
+ if field == "event":
81
+ event_type = value
82
+ elif field == "id":
83
+ event_id = value
84
+ elif field == "data":
85
+ data_lines.append(value)
86
+
87
+ if not data_lines and event_type is None:
88
+ return None
89
+
90
+ raw_data = "\n".join(data_lines)
91
+ if raw_data.strip() == "[DONE]":
92
+ return _DONE_SENTINEL
93
+
94
+ parsed: object
95
+ if raw_data == "":
96
+ parsed = {}
97
+ else:
98
+ try:
99
+ parsed = json.loads(raw_data)
100
+ except json.JSONDecodeError:
101
+ parsed = raw_data # fall back to raw string payload
102
+
103
+ return {
104
+ "event": event_type or "message",
105
+ "data": parsed,
106
+ "id": event_id,
107
+ }
108
+
109
+
110
+ def iter_sse_sync(byte_iter: Iterator[bytes]) -> Iterator[Dict[str, object]]:
111
+ acc = _SseAccumulator()
112
+ for chunk in byte_iter:
113
+ if not chunk:
114
+ continue
115
+ acc.feed(chunk.decode("utf-8", errors="replace"))
116
+ for evt in acc.pop_frames():
117
+ yield evt
118
+ if acc.done:
119
+ return
120
+
121
+
122
+ async def iter_sse_async(byte_iter: AsyncIterator[bytes]) -> AsyncIterator[Dict[str, object]]:
123
+ acc = _SseAccumulator()
124
+ async for chunk in byte_iter:
125
+ if not chunk:
126
+ continue
127
+ acc.feed(chunk.decode("utf-8", errors="replace"))
128
+ for evt in acc.pop_frames():
129
+ yield evt
130
+ if acc.done:
131
+ return
brime/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
brime/async_client.py ADDED
@@ -0,0 +1,308 @@
1
+ """Asynchronous Brime client (mirror of brime.client.Brime)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, AsyncIterator, Dict, List, Literal, Optional, Union
6
+
7
+ import httpx
8
+
9
+ from brime._http import (
10
+ DEEP_RESEARCH_TIMEOUT_S,
11
+ DEFAULT_TIMEOUT_S,
12
+ build_headers,
13
+ decode_response,
14
+ new_idempotency_key,
15
+ resolve_api_key,
16
+ resolve_base_url,
17
+ wrap_transport_error,
18
+ )
19
+ from brime._polling import poll_until_terminal_async
20
+ from brime._sse import iter_sse_async
21
+ from brime.errors import BrimeError
22
+ from brime.models.extract import ExtractResponse
23
+ from brime.models.research import (
24
+ ResearchBasicResponse,
25
+ ResearchDeepInitResponse,
26
+ ResearchSseEvent,
27
+ ResearchStatusResponse,
28
+ )
29
+ from brime.models.search import SearchResponse
30
+
31
+
32
+ class AsyncBrime:
33
+ """Asynchronous Brime API client."""
34
+
35
+ def __init__(
36
+ self,
37
+ api_key: Optional[str] = None,
38
+ *,
39
+ base_url: Optional[str] = None,
40
+ timeout: float = DEFAULT_TIMEOUT_S,
41
+ ) -> None:
42
+ self._api_key = resolve_api_key(api_key)
43
+ self._base_url = resolve_base_url(base_url)
44
+ self._timeout = timeout
45
+ self._client = httpx.AsyncClient(base_url=self._base_url, timeout=timeout)
46
+
47
+ async def __aenter__(self) -> "AsyncBrime":
48
+ return self
49
+
50
+ async def __aexit__(self, *exc: Any) -> None:
51
+ await self.aclose()
52
+
53
+ async def aclose(self) -> None:
54
+ await self._client.aclose()
55
+
56
+ # ── Search ─────────────────────────────────────────────────────────
57
+
58
+ async def search(
59
+ self,
60
+ query: str,
61
+ *,
62
+ depth: Literal["instant", "basic", "advanced"] = "basic",
63
+ topic: Literal["general", "news", "finance"] = "general",
64
+ max_results: int = 5,
65
+ time_range: Optional[Literal["day", "week", "month", "year"]] = None,
66
+ start_date: Optional[str] = None,
67
+ end_date: Optional[str] = None,
68
+ include_answer: Union[bool, Literal["basic", "advanced"]] = True,
69
+ include_images: bool = False,
70
+ domains: Optional[List[str]] = None,
71
+ exclude_domains: Optional[List[str]] = None,
72
+ timeout: Optional[float] = None,
73
+ ) -> SearchResponse:
74
+ body: Dict[str, Any] = {
75
+ "query": query,
76
+ "depth": depth,
77
+ "topic": topic,
78
+ "max_results": max_results,
79
+ "include_answer": include_answer,
80
+ "include_images": include_images,
81
+ }
82
+ if time_range:
83
+ body["time_range"] = time_range
84
+ if start_date:
85
+ body["start_date"] = start_date
86
+ if end_date:
87
+ body["end_date"] = end_date
88
+ if domains:
89
+ body["domains"] = domains
90
+ if exclude_domains:
91
+ body["exclude_domains"] = exclude_domains
92
+ data = await self._post_json("/v1/search", body, timeout=timeout)
93
+ return SearchResponse.model_validate(data)
94
+
95
+ # ── Extract ────────────────────────────────────────────────────────
96
+
97
+ async def extract(
98
+ self,
99
+ urls: Union[str, List[str]],
100
+ *,
101
+ include_metadata: bool = False,
102
+ per_url_timeout_ms: int = 25_000,
103
+ idempotency_key: Optional[str] = None,
104
+ timeout: Optional[float] = None,
105
+ ) -> ExtractResponse:
106
+ url_list = [urls] if isinstance(urls, str) else list(urls)
107
+ body: Dict[str, Any] = {
108
+ "urls": url_list,
109
+ "include_metadata": include_metadata,
110
+ "per_url_timeout_ms": per_url_timeout_ms,
111
+ }
112
+ idem = idempotency_key or new_idempotency_key()
113
+ data = await self._post_json(
114
+ "/v1/extract", body, idempotency_key=idem, timeout=timeout
115
+ )
116
+ return ExtractResponse.model_validate(data)
117
+
118
+ # ── Research ───────────────────────────────────────────────────────
119
+
120
+ async def research(
121
+ self,
122
+ query: str,
123
+ *,
124
+ depth: Literal["basic", "deep"] = "basic",
125
+ max_rounds: Optional[int] = None,
126
+ fast: bool = False,
127
+ scrape: bool = True,
128
+ query_gen: bool = True,
129
+ topic: Literal["general", "news", "finance"] = "general",
130
+ max_results: int = 5,
131
+ time_range: Optional[Literal["day", "week", "month", "year"]] = None,
132
+ wait: bool = False,
133
+ poll_interval: float = 5.0,
134
+ max_poll_interval: float = 30.0,
135
+ poll_timeout: float = DEEP_RESEARCH_TIMEOUT_S,
136
+ idempotency_key: Optional[str] = None,
137
+ timeout: Optional[float] = None,
138
+ ) -> Union[ResearchBasicResponse, ResearchDeepInitResponse, ResearchStatusResponse]:
139
+ body: Dict[str, Any] = {
140
+ "query": query,
141
+ "depth": depth,
142
+ "fast": fast,
143
+ "scrape": scrape,
144
+ "query_gen": query_gen,
145
+ "topic": topic,
146
+ "max_results": max_results,
147
+ }
148
+ if max_rounds is not None:
149
+ body["max_rounds"] = max_rounds
150
+ if time_range:
151
+ body["time_range"] = time_range
152
+
153
+ idem = idempotency_key
154
+ if depth == "deep" and idem is None:
155
+ idem = new_idempotency_key()
156
+ data = await self._post_json(
157
+ "/v1/research", body, idempotency_key=idem, timeout=timeout
158
+ )
159
+ if depth == "basic":
160
+ return ResearchBasicResponse.model_validate(data)
161
+ init = ResearchDeepInitResponse.model_validate(data)
162
+ if not wait:
163
+ return init
164
+
165
+ async def fetch() -> ResearchStatusResponse:
166
+ return await self.research_status(init.job_id)
167
+
168
+ return await poll_until_terminal_async(
169
+ fetch,
170
+ initial_interval=poll_interval,
171
+ max_interval=max_poll_interval,
172
+ poll_timeout=poll_timeout,
173
+ )
174
+
175
+ async def research_status(self, job_id: str) -> ResearchStatusResponse:
176
+ data = await self._get_json(f"/v1/research/{job_id}")
177
+ return ResearchStatusResponse.model_validate(data)
178
+
179
+ async def research_stream(
180
+ self,
181
+ query: str,
182
+ *,
183
+ depth: Literal["basic", "deep"] = "deep",
184
+ max_rounds: Optional[int] = None,
185
+ topic: Literal["general", "news", "finance"] = "general",
186
+ max_results: int = 5,
187
+ last_event_id: Optional[str] = None,
188
+ timeout: Optional[float] = None,
189
+ ) -> AsyncIterator[ResearchSseEvent]:
190
+ if depth == "basic":
191
+ async for evt in self._sse_post(
192
+ "/v1/research",
193
+ body={
194
+ "query": query,
195
+ "depth": "basic",
196
+ "max_rounds": max_rounds or 1,
197
+ "topic": topic,
198
+ "max_results": max_results,
199
+ "stream": True,
200
+ },
201
+ last_event_id=last_event_id,
202
+ timeout=timeout,
203
+ ):
204
+ yield evt
205
+ return
206
+ init = await self.research(
207
+ query,
208
+ depth="deep",
209
+ max_rounds=max_rounds,
210
+ topic=topic,
211
+ max_results=max_results,
212
+ wait=False,
213
+ timeout=timeout,
214
+ )
215
+ assert isinstance(init, ResearchDeepInitResponse)
216
+ async for evt in self._sse_get(
217
+ init.stream_url, last_event_id=last_event_id, timeout=timeout
218
+ ):
219
+ yield evt
220
+
221
+ # ── Internal HTTP plumbing ────────────────────────────────────────
222
+
223
+ async def _post_json(
224
+ self,
225
+ path: str,
226
+ body: Dict[str, Any],
227
+ *,
228
+ idempotency_key: Optional[str] = None,
229
+ timeout: Optional[float] = None,
230
+ ) -> Any:
231
+ headers = build_headers(self._api_key, idempotency_key=idempotency_key)
232
+ try:
233
+ res = await self._client.post(
234
+ path, json=body, headers=headers, timeout=timeout or self._timeout
235
+ )
236
+ except httpx.HTTPError as exc:
237
+ raise wrap_transport_error(exc) from exc
238
+ return decode_response(res)
239
+
240
+ async def _get_json(self, path: str, *, timeout: Optional[float] = None) -> Any:
241
+ headers = build_headers(self._api_key, json_body=False)
242
+ try:
243
+ res = await self._client.get(
244
+ path, headers=headers, timeout=timeout or self._timeout
245
+ )
246
+ except httpx.HTTPError as exc:
247
+ raise wrap_transport_error(exc) from exc
248
+ return decode_response(res)
249
+
250
+ async def _sse_post(
251
+ self,
252
+ path: str,
253
+ *,
254
+ body: Dict[str, Any],
255
+ last_event_id: Optional[str],
256
+ timeout: Optional[float],
257
+ ) -> AsyncIterator[ResearchSseEvent]:
258
+ extra: Dict[str, str] = {}
259
+ if last_event_id:
260
+ extra["last-event-id"] = last_event_id
261
+ headers = build_headers(self._api_key, accept="text/event-stream", extra=extra)
262
+ try:
263
+ async with self._client.stream(
264
+ "POST",
265
+ path,
266
+ json=body,
267
+ headers=headers,
268
+ timeout=timeout or DEEP_RESEARCH_TIMEOUT_S,
269
+ ) as res:
270
+ if not res.is_success:
271
+ await res.aread()
272
+ decode_response(res)
273
+ async for evt in iter_sse_async(res.aiter_bytes()):
274
+ yield ResearchSseEvent.model_validate(evt)
275
+ except httpx.HTTPError as exc:
276
+ if isinstance(exc, BrimeError): # pragma: no cover
277
+ raise
278
+ raise wrap_transport_error(exc) from exc
279
+
280
+ async def _sse_get(
281
+ self,
282
+ path: str,
283
+ *,
284
+ last_event_id: Optional[str],
285
+ timeout: Optional[float],
286
+ ) -> AsyncIterator[ResearchSseEvent]:
287
+ extra: Dict[str, str] = {}
288
+ if last_event_id:
289
+ extra["last-event-id"] = last_event_id
290
+ headers = build_headers(
291
+ self._api_key, json_body=False, accept="text/event-stream", extra=extra
292
+ )
293
+ try:
294
+ async with self._client.stream(
295
+ "GET",
296
+ path,
297
+ headers=headers,
298
+ timeout=timeout or DEEP_RESEARCH_TIMEOUT_S,
299
+ ) as res:
300
+ if not res.is_success:
301
+ await res.aread()
302
+ decode_response(res)
303
+ async for evt in iter_sse_async(res.aiter_bytes()):
304
+ yield ResearchSseEvent.model_validate(evt)
305
+ except httpx.HTTPError as exc:
306
+ if isinstance(exc, BrimeError): # pragma: no cover
307
+ raise
308
+ raise wrap_transport_error(exc) from exc