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 +58 -0
- brime/_http.py +99 -0
- brime/_polling.py +72 -0
- brime/_sse.py +131 -0
- brime/_version.py +1 -0
- brime/async_client.py +308 -0
- brime/client.py +323 -0
- brime/errors.py +128 -0
- brime/models/__init__.py +0 -0
- brime/models/extract.py +64 -0
- brime/models/research.py +83 -0
- brime/models/search.py +26 -0
- brime/py.typed +0 -0
- brime-0.1.0.dist-info/METADATA +243 -0
- brime-0.1.0.dist-info/RECORD +17 -0
- brime-0.1.0.dist-info/WHEEL +4 -0
- brime-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|