getabrain 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ dist/
2
+ build/
3
+ *.egg-info/
4
+ __pycache__/
5
+ .pytest_cache/
6
+ .mypy_cache/
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: getabrain
3
+ Version: 0.1.0
4
+ Summary: Official Python SDK for GetABrain.ai — real human judgment as an API
5
+ Project-URL: Homepage, https://getabrain.ai
6
+ Project-URL: Documentation, https://getabrain.ai/docs/api
7
+ Project-URL: Source, https://github.com/Guitarmaniac24/Getabrain.ai
8
+ Project-URL: Bug Tracker, https://github.com/Guitarmaniac24/Getabrain.ai/issues
9
+ Author-email: GetABrain <hello@getabrain.ai>
10
+ License-Expression: MIT
11
+ Keywords: ai,api,data-labeling,getabrain,human-in-the-loop,rlhf,sdk
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Python: >=3.9
21
+ Requires-Dist: httpx>=0.27
22
+ Description-Content-Type: text/markdown
23
+
24
+ # getabrain
25
+
26
+ Official Python SDK for [GetABrain.ai](https://getabrain.ai) — real human judgment as an API.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pip install getabrain
32
+ ```
33
+
34
+ ## Quickstart
35
+
36
+ ```python
37
+ from getabrain import GetABrain, InsufficientBalanceError
38
+
39
+ gab = GetABrain(api_key="gab_k_...", api_secret="gab_s_...")
40
+
41
+ # Submit a query (returns immediately)
42
+ query = gab.queries.create(
43
+ type="ab_test",
44
+ title="Which headline converts better?",
45
+ content_data={
46
+ "question": "Which is more compelling?",
47
+ "variant_a": {"description": "Save 20% today"},
48
+ "variant_b": {"description": "Your future self will thank you"},
49
+ },
50
+ required_responses=5,
51
+ bid_amount_cents=25,
52
+ )
53
+
54
+ # Wait for humans to answer (polls for you)
55
+ try:
56
+ responses = gab.queries.wait_for_responses(query["id"], min_responses=5, timeout_s=600)
57
+ for r in responses:
58
+ print(r["response_data"])
59
+ except InsufficientBalanceError:
60
+ print("Top up your balance at https://getabrain.ai")
61
+ ```
62
+
63
+ ## API
64
+
65
+ - `gab.queries.create(type=..., title=..., content_data={...}, required_responses=..., bid_amount_cents=...)`
66
+ - `gab.queries.get(query_id)` · `gab.queries.list(status=..., limit=...)` · `gab.queries.cancel(query_id)`
67
+ - `gab.queries.wait_for_responses(query_id, min_responses=..., timeout_s=600, poll_interval_s=5)`
68
+ - `gab.responses.rate(query_id, response_id, score=5, feedback_text="...")` · `gab.responses.get_rating(query_id, response_id)`
69
+ - `gab.account.stats()` · `gab.account.balance()`
70
+
71
+ Query types: `text`, `multiple_choice`, `rating_scale`, `image_comparison`, `ranking`, `yes_no`, `sentiment`, `image_selection`, `free_form_text`, `video_review`, `audio_review`, `image_analysis`, `ab_test`.
72
+
73
+ ## Errors
74
+
75
+ All errors subclass `GetABrainError`: `ValidationError`, `AuthError`, `InsufficientBalanceError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError` (`.retry_after_ms`), `ServerError`, `TimeoutError`, `NetworkError`.
76
+
77
+ Full API docs: https://getabrain.ai/docs/api
@@ -0,0 +1,54 @@
1
+ # getabrain
2
+
3
+ Official Python SDK for [GetABrain.ai](https://getabrain.ai) — real human judgment as an API.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install getabrain
9
+ ```
10
+
11
+ ## Quickstart
12
+
13
+ ```python
14
+ from getabrain import GetABrain, InsufficientBalanceError
15
+
16
+ gab = GetABrain(api_key="gab_k_...", api_secret="gab_s_...")
17
+
18
+ # Submit a query (returns immediately)
19
+ query = gab.queries.create(
20
+ type="ab_test",
21
+ title="Which headline converts better?",
22
+ content_data={
23
+ "question": "Which is more compelling?",
24
+ "variant_a": {"description": "Save 20% today"},
25
+ "variant_b": {"description": "Your future self will thank you"},
26
+ },
27
+ required_responses=5,
28
+ bid_amount_cents=25,
29
+ )
30
+
31
+ # Wait for humans to answer (polls for you)
32
+ try:
33
+ responses = gab.queries.wait_for_responses(query["id"], min_responses=5, timeout_s=600)
34
+ for r in responses:
35
+ print(r["response_data"])
36
+ except InsufficientBalanceError:
37
+ print("Top up your balance at https://getabrain.ai")
38
+ ```
39
+
40
+ ## API
41
+
42
+ - `gab.queries.create(type=..., title=..., content_data={...}, required_responses=..., bid_amount_cents=...)`
43
+ - `gab.queries.get(query_id)` · `gab.queries.list(status=..., limit=...)` · `gab.queries.cancel(query_id)`
44
+ - `gab.queries.wait_for_responses(query_id, min_responses=..., timeout_s=600, poll_interval_s=5)`
45
+ - `gab.responses.rate(query_id, response_id, score=5, feedback_text="...")` · `gab.responses.get_rating(query_id, response_id)`
46
+ - `gab.account.stats()` · `gab.account.balance()`
47
+
48
+ Query types: `text`, `multiple_choice`, `rating_scale`, `image_comparison`, `ranking`, `yes_no`, `sentiment`, `image_selection`, `free_form_text`, `video_review`, `audio_review`, `image_analysis`, `ab_test`.
49
+
50
+ ## Errors
51
+
52
+ All errors subclass `GetABrainError`: `ValidationError`, `AuthError`, `InsufficientBalanceError`, `ForbiddenError`, `NotFoundError`, `ConflictError`, `RateLimitError` (`.retry_after_ms`), `ServerError`, `TimeoutError`, `NetworkError`.
53
+
54
+ Full API docs: https://getabrain.ai/docs/api
@@ -0,0 +1,37 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "getabrain"
7
+ version = "0.1.0"
8
+ description = "Official Python SDK for GetABrain.ai — real human judgment as an API"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ authors = [{ name = "GetABrain", email = "hello@getabrain.ai" }]
13
+ keywords = ["getabrain", "human-in-the-loop", "ai", "api", "sdk", "rlhf", "data-labeling"]
14
+ dependencies = ["httpx>=0.27"]
15
+ classifiers = [
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.9",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "License :: OSI Approved :: MIT License",
22
+ "Intended Audience :: Developers",
23
+ "Topic :: Software Development :: Libraries :: Python Modules",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://getabrain.ai"
28
+ Documentation = "https://getabrain.ai/docs/api"
29
+ Source = "https://github.com/Guitarmaniac24/Getabrain.ai"
30
+ "Bug Tracker" = "https://github.com/Guitarmaniac24/Getabrain.ai/issues"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/getabrain"]
34
+
35
+ [tool.pytest.ini_options]
36
+ pythonpath = ["src"]
37
+ testpaths = ["tests"]
@@ -0,0 +1,57 @@
1
+ """getabrain — official Python SDK for GetABrain.ai."""
2
+ from __future__ import annotations
3
+
4
+ from .client import GetABrain
5
+ from .errors import (
6
+ GetABrainError,
7
+ ValidationError,
8
+ AuthError,
9
+ InsufficientBalanceError,
10
+ ForbiddenError,
11
+ NotFoundError,
12
+ ConflictError,
13
+ ServerError,
14
+ RateLimitError,
15
+ TimeoutError,
16
+ NetworkError,
17
+ error_from_response,
18
+ )
19
+ from .types import (
20
+ QUERY_TYPES,
21
+ QueryType,
22
+ Query,
23
+ QueryResponse,
24
+ QueryList,
25
+ RatingResult,
26
+ Rating,
27
+ AccountStats,
28
+ BillingInfo,
29
+ )
30
+
31
+ __version__ = "0.1.0"
32
+
33
+ __all__ = [
34
+ "GetABrain",
35
+ "GetABrainError",
36
+ "ValidationError",
37
+ "AuthError",
38
+ "InsufficientBalanceError",
39
+ "ForbiddenError",
40
+ "NotFoundError",
41
+ "ConflictError",
42
+ "ServerError",
43
+ "RateLimitError",
44
+ "TimeoutError",
45
+ "NetworkError",
46
+ "error_from_response",
47
+ "QUERY_TYPES",
48
+ "QueryType",
49
+ "Query",
50
+ "QueryResponse",
51
+ "QueryList",
52
+ "RatingResult",
53
+ "Rating",
54
+ "AccountStats",
55
+ "BillingInfo",
56
+ "__version__",
57
+ ]
@@ -0,0 +1,85 @@
1
+ """HTTP transport: builds an httpx.Client and a `_request` callable with auth,
2
+ JSON-envelope error mapping, and GET-only retries."""
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from typing import Any, Callable, Dict, Optional, Tuple
7
+
8
+ import httpx
9
+
10
+ from .errors import NetworkError, TimeoutError, error_from_response
11
+
12
+ DEFAULT_BASE_URL = "https://www.getabrain.ai/api/v1"
13
+
14
+ RequestFn = Callable[..., Any]
15
+
16
+
17
+ def _backoff(attempt: int) -> float:
18
+ return min(2.0 ** (attempt - 1), 8.0)
19
+
20
+
21
+ def _parse_retry_after(value: Optional[str]) -> Optional[int]:
22
+ if not value:
23
+ return None
24
+ try:
25
+ return int(float(value) * 1000)
26
+ except ValueError:
27
+ return None
28
+
29
+
30
+ def build_transport(
31
+ *,
32
+ api_key: str,
33
+ api_secret: str,
34
+ base_url: Optional[str] = None,
35
+ timeout_s: float = 30.0,
36
+ max_retries: int = 2,
37
+ transport: Optional[httpx.BaseTransport] = None,
38
+ sleep: Callable[[float], None] = time.sleep,
39
+ ) -> Tuple[httpx.Client, RequestFn]:
40
+ base = (base_url or DEFAULT_BASE_URL).rstrip("/")
41
+ client = httpx.Client(
42
+ timeout=timeout_s,
43
+ transport=transport,
44
+ headers={
45
+ "X-API-Key": api_key,
46
+ "X-API-Secret": api_secret,
47
+ "Content-Type": "application/json",
48
+ },
49
+ )
50
+
51
+ def request(method: str, path: str, *, body: Any = None, params: Optional[Dict[str, Any]] = None) -> Any:
52
+ url = base + path
53
+ clean = {k: v for k, v in (params or {}).items() if v is not None}
54
+ is_read = method.upper() == "GET"
55
+ attempt = 0
56
+ while True:
57
+ try:
58
+ resp = client.request(method, url, json=body, params=clean or None)
59
+ except httpx.TimeoutException:
60
+ raise TimeoutError()
61
+ except httpx.TransportError as exc:
62
+ if is_read and attempt < max_retries:
63
+ attempt += 1
64
+ sleep(_backoff(attempt))
65
+ continue
66
+ raise NetworkError(str(exc))
67
+
68
+ if resp.is_success:
69
+ if resp.status_code == 204:
70
+ return None
71
+ return resp.json()
72
+
73
+ retry_after_ms = _parse_retry_after(resp.headers.get("retry-after"))
74
+ if is_read and (resp.status_code == 429 or resp.status_code >= 500) and attempt < max_retries:
75
+ attempt += 1
76
+ sleep(retry_after_ms / 1000 if retry_after_ms is not None else _backoff(attempt))
77
+ continue
78
+
79
+ try:
80
+ err_body = resp.json()
81
+ except Exception:
82
+ err_body = None
83
+ raise error_from_response(resp.status_code, err_body, retry_after_ms)
84
+
85
+ return client, request
@@ -0,0 +1,58 @@
1
+ """The GetABrain client — wires config -> transport -> resources."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from typing import Callable, Optional
6
+
7
+ import httpx
8
+
9
+ from ._http import build_transport
10
+ from .resources.account import AccountResource
11
+ from .resources.queries import QueriesResource
12
+ from .resources.responses import ResponsesResource
13
+
14
+
15
+ class GetABrain:
16
+ """Client for the GetABrain requestor API.
17
+
18
+ Example:
19
+ gab = GetABrain(api_key="gab_k_...", api_secret="gab_s_...")
20
+ q = gab.queries.create(type="yes_no", title="Is it live?",
21
+ content_data={"question": "Is it live?"},
22
+ required_responses=2, bid_amount_cents=50)
23
+ responses = gab.queries.wait_for_responses(q["id"], min_responses=2)
24
+ """
25
+
26
+ def __init__(
27
+ self,
28
+ api_key: str,
29
+ api_secret: str,
30
+ *,
31
+ base_url: Optional[str] = None,
32
+ timeout_s: float = 30.0,
33
+ max_retries: int = 2,
34
+ transport: Optional[httpx.BaseTransport] = None,
35
+ sleep: Callable[[float], None] = time.sleep,
36
+ ) -> None:
37
+ self._client, request = build_transport(
38
+ api_key=api_key,
39
+ api_secret=api_secret,
40
+ base_url=base_url,
41
+ timeout_s=timeout_s,
42
+ max_retries=max_retries,
43
+ transport=transport,
44
+ sleep=sleep,
45
+ )
46
+ self.queries = QueriesResource(request)
47
+ self.responses = ResponsesResource(request)
48
+ self.account = AccountResource(request)
49
+
50
+ def close(self) -> None:
51
+ """Close the underlying HTTP client."""
52
+ self._client.close()
53
+
54
+ def __enter__(self) -> "GetABrain":
55
+ return self
56
+
57
+ def __exit__(self, *exc: object) -> None:
58
+ self.close()
@@ -0,0 +1,103 @@
1
+ """Typed error hierarchy mirroring the GetABrain HTTP envelope."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Optional
5
+
6
+
7
+ class GetABrainError(Exception):
8
+ """Base error. `status` is the HTTP status (0 for client-side errors)."""
9
+
10
+ def __init__(
11
+ self,
12
+ message: str,
13
+ status: int = 0,
14
+ code: str = "error",
15
+ request_id: Optional[str] = None,
16
+ ) -> None:
17
+ super().__init__(message)
18
+ self.message = message
19
+ self.status = status
20
+ self.code = code
21
+ self.request_id = request_id
22
+
23
+
24
+ class ValidationError(GetABrainError):
25
+ pass
26
+
27
+
28
+ class AuthError(GetABrainError):
29
+ pass
30
+
31
+
32
+ class InsufficientBalanceError(GetABrainError):
33
+ pass
34
+
35
+
36
+ class ForbiddenError(GetABrainError):
37
+ pass
38
+
39
+
40
+ class NotFoundError(GetABrainError):
41
+ pass
42
+
43
+
44
+ class ConflictError(GetABrainError):
45
+ pass
46
+
47
+
48
+ class ServerError(GetABrainError):
49
+ pass
50
+
51
+
52
+ class RateLimitError(GetABrainError):
53
+ def __init__(
54
+ self,
55
+ message: str,
56
+ status: int = 429,
57
+ code: str = "error",
58
+ retry_after_ms: Optional[int] = None,
59
+ request_id: Optional[str] = None,
60
+ ) -> None:
61
+ super().__init__(message, status, code, request_id)
62
+ self.retry_after_ms = retry_after_ms
63
+
64
+
65
+ class TimeoutError(GetABrainError):
66
+ """Request timed out, or wait_for_responses exceeded its deadline.
67
+
68
+ Subclasses GetABrainError (NOT the builtin TimeoutError) so callers can
69
+ `except GetABrainError`.
70
+ """
71
+
72
+ def __init__(self, message: str = "Request timed out") -> None:
73
+ super().__init__(message, 0, "timeout")
74
+
75
+
76
+ class NetworkError(GetABrainError):
77
+ def __init__(self, message: str = "Network request failed") -> None:
78
+ super().__init__(message, 0, "network")
79
+
80
+
81
+ def error_from_response(
82
+ status: int, body: Any, retry_after_ms: Optional[int] = None
83
+ ) -> GetABrainError:
84
+ b = body if isinstance(body, dict) else {}
85
+ message = b.get("message") or b.get("error") or f"HTTP {status}"
86
+ code = b.get("error") or "error"
87
+ if status == 400:
88
+ return ValidationError(message, status, code)
89
+ if status == 401:
90
+ return AuthError(message, status, code)
91
+ if status == 402:
92
+ return InsufficientBalanceError(message, status, code)
93
+ if status == 403:
94
+ return ForbiddenError(message, status, code)
95
+ if status == 404:
96
+ return NotFoundError(message, status, code)
97
+ if status == 409:
98
+ return ConflictError(message, status, code)
99
+ if status == 429:
100
+ return RateLimitError(message, status, code, retry_after_ms)
101
+ if status >= 500:
102
+ return ServerError(message, status, code)
103
+ return GetABrainError(message, status, code)
File without changes
File without changes
@@ -0,0 +1,16 @@
1
+ """Account resource (stats + billing)."""
2
+ from __future__ import annotations
3
+
4
+ from .._http import RequestFn
5
+ from ..types import AccountStats, BillingInfo
6
+
7
+
8
+ class AccountResource:
9
+ def __init__(self, request: RequestFn) -> None:
10
+ self._request = request
11
+
12
+ def stats(self) -> AccountStats:
13
+ return self._request("GET", "/requestor/stats")
14
+
15
+ def balance(self) -> BillingInfo:
16
+ return self._request("GET", "/requestor/billing")
@@ -0,0 +1,68 @@
1
+ """Queries resource."""
2
+ from __future__ import annotations
3
+
4
+ import time
5
+ from urllib.parse import quote
6
+ from typing import Any, Callable, Dict, List, Optional
7
+
8
+ from .._http import RequestFn
9
+ from ..errors import TimeoutError
10
+ from ..types import Query, QueryList, QueryResponse
11
+
12
+ _TERMINAL = {"completed", "cancelled", "failed", "expired"}
13
+
14
+
15
+ class QueriesResource:
16
+ def __init__(self, request: RequestFn) -> None:
17
+ self._request = request
18
+
19
+ def create(self, **input: Any) -> Query:
20
+ return self._request("POST", "/requestor/queries", body=input)
21
+
22
+ def get(self, query_id: str) -> Query:
23
+ return self._request("GET", f"/requestor/queries/{quote(query_id, safe='')}")
24
+
25
+ def list(
26
+ self,
27
+ *,
28
+ status: Optional[str] = None,
29
+ limit: Optional[int] = None,
30
+ offset: Optional[int] = None,
31
+ ) -> QueryList:
32
+ return self._request(
33
+ "GET", "/requestor/queries",
34
+ params={"status": status, "limit": limit, "offset": offset},
35
+ )
36
+
37
+ def cancel(self, query_id: str) -> Dict[str, Any]:
38
+ return self._request(
39
+ "PUT", f"/requestor/queries/{quote(query_id, safe='')}",
40
+ body={"action": "cancel"},
41
+ )
42
+
43
+ def wait_for_responses(
44
+ self,
45
+ query_id: str,
46
+ *,
47
+ min_responses: int,
48
+ timeout_s: float = 600.0,
49
+ poll_interval_s: float = 5.0,
50
+ sleep: Callable[[float], None] = time.sleep,
51
+ now: Callable[[], float] = time.monotonic,
52
+ ) -> List[QueryResponse]:
53
+ deadline = now() + timeout_s
54
+ while True:
55
+ query = self.get(query_id)
56
+ responses = query.get("responses") or []
57
+ completed = query.get("completed_responses")
58
+ if completed is None:
59
+ completed = len(responses)
60
+ if completed >= min_responses:
61
+ return responses
62
+ if query.get("status") in _TERMINAL:
63
+ return responses
64
+ if now() >= deadline:
65
+ raise TimeoutError(
66
+ f"wait_for_responses timed out after {timeout_s}s for query {query_id}"
67
+ )
68
+ sleep(poll_interval_s)
@@ -0,0 +1,36 @@
1
+ """Responses resource (ratings)."""
2
+ from __future__ import annotations
3
+
4
+ from urllib.parse import quote
5
+ from typing import Optional
6
+
7
+ from .._http import RequestFn
8
+ from ..types import Rating, RatingResult
9
+
10
+
11
+ class ResponsesResource:
12
+ def __init__(self, request: RequestFn) -> None:
13
+ self._request = request
14
+
15
+ def rate(
16
+ self,
17
+ query_id: str,
18
+ response_id: str,
19
+ *,
20
+ score: int,
21
+ feedback_text: Optional[str] = None,
22
+ ) -> RatingResult:
23
+ q = quote(query_id, safe="")
24
+ r = quote(response_id, safe="")
25
+ body: dict = {"score": score}
26
+ if feedback_text is not None:
27
+ body["feedback_text"] = feedback_text
28
+ return self._request(
29
+ "POST", f"/requestor/queries/{q}/responses/{r}/rate",
30
+ body=body,
31
+ )
32
+
33
+ def get_rating(self, query_id: str, response_id: str) -> Rating:
34
+ q = quote(query_id, safe="")
35
+ r = quote(response_id, safe="")
36
+ return self._request("GET", f"/requestor/queries/{q}/responses/{r}/rate")
@@ -0,0 +1,225 @@
1
+ """Query-type Literal + content/response TypedDicts (mirror of the OpenAPI contract).
2
+
3
+ All TypedDicts are `total=False`: the SDK is a thin typed client and the server is
4
+ the validation authority, so every field is optional at the type level. They give
5
+ IDE help; they do not enforce required keys at runtime.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any, Dict, List, Literal, Optional, TypedDict
10
+
11
+ QUERY_TYPES = (
12
+ "text",
13
+ "multiple_choice",
14
+ "rating_scale",
15
+ "image_comparison",
16
+ "ranking",
17
+ "yes_no",
18
+ "sentiment",
19
+ "image_selection",
20
+ "free_form_text",
21
+ "video_review",
22
+ "audio_review",
23
+ "image_analysis",
24
+ "ab_test",
25
+ )
26
+
27
+ QueryType = Literal[
28
+ "text",
29
+ "multiple_choice",
30
+ "rating_scale",
31
+ "image_comparison",
32
+ "ranking",
33
+ "yes_no",
34
+ "sentiment",
35
+ "image_selection",
36
+ "free_form_text",
37
+ "video_review",
38
+ "audio_review",
39
+ "image_analysis",
40
+ "ab_test",
41
+ ]
42
+
43
+
44
+ class TextContent(TypedDict, total=False):
45
+ question: str
46
+ instructions: str
47
+ context: str
48
+ min_words: int
49
+ max_words: int
50
+
51
+
52
+ class MultipleChoiceContent(TypedDict, total=False):
53
+ question: str
54
+ instructions: str
55
+ context: str
56
+ options: List[Dict[str, Any]]
57
+ allow_multiple: bool
58
+ min_selections: int
59
+ max_selections: int
60
+
61
+
62
+ class RatingScaleContent(TypedDict, total=False):
63
+ question: str
64
+ instructions: str
65
+ context: str
66
+ scale_type: Literal["1-5", "1-10", "0-100", "stars"]
67
+ scale_min: int
68
+ scale_max: int
69
+ scale_labels: Dict[str, str]
70
+ criteria: List[str]
71
+
72
+
73
+ class ImageComparisonContent(TypedDict, total=False):
74
+ question: str
75
+ instructions: str
76
+ context: str
77
+ images: List[Dict[str, Any]]
78
+ comparison_type: Literal["preference", "quality", "relevance", "custom"]
79
+ require_reasoning: bool
80
+
81
+
82
+ class RankingContent(TypedDict, total=False):
83
+ question: str
84
+ instructions: str
85
+ context: str
86
+ items: List[Dict[str, Any]]
87
+ max_items: int
88
+ require_all: bool
89
+
90
+
91
+ class YesNoContent(TypedDict, total=False):
92
+ question: str
93
+ instructions: str
94
+ context: str
95
+ require_explanation: bool
96
+ min_explanation_words: int
97
+
98
+
99
+ class SentimentContent(TypedDict, total=False):
100
+ question: str
101
+ instructions: str
102
+ context: str
103
+ subject: str
104
+ sentiment_options: List[str]
105
+ require_reasoning: bool
106
+
107
+
108
+ class ImageSelectionContent(TypedDict, total=False):
109
+ question: str
110
+ instructions: str
111
+ context: str
112
+ images: List[Dict[str, Any]]
113
+ min_selections: int
114
+ max_selections: int
115
+ selection_criteria: str
116
+
117
+
118
+ class FreeFormTextContent(TypedDict, total=False):
119
+ question: str
120
+ instructions: str
121
+ context: str
122
+ prompt: str
123
+ min_characters: int
124
+ max_characters: int
125
+ format_guidelines: str
126
+
127
+
128
+ class VideoReviewContent(TypedDict, total=False):
129
+ question: str
130
+ instructions: str
131
+ context: str
132
+ video_url: str
133
+ video_duration_seconds: int
134
+ review_aspects: List[str]
135
+ min_watch_time_seconds: int
136
+
137
+
138
+ class AudioReviewContent(TypedDict, total=False):
139
+ question: str
140
+ instructions: str
141
+ context: str
142
+ audio_url: str
143
+ audio_duration_seconds: int
144
+ review_aspects: List[str]
145
+ transcription_required: bool
146
+
147
+
148
+ class ImageAnalysisContent(TypedDict, total=False):
149
+ question: str
150
+ image_url: str
151
+
152
+
153
+ class AbTestContent(TypedDict, total=False):
154
+ question: str
155
+ variant_a: Dict[str, Any]
156
+ variant_b: Dict[str, Any]
157
+
158
+
159
+ class QueryResponse(TypedDict, total=False):
160
+ id: str
161
+ response_data: Dict[str, Any]
162
+ status: str
163
+ submitted_at: str
164
+ worker: Dict[str, Any]
165
+ rating: Optional[Dict[str, Any]]
166
+
167
+
168
+ class Query(TypedDict, total=False):
169
+ id: str
170
+ type: str
171
+ title: str
172
+ description: str
173
+ status: str
174
+ required_responses: int
175
+ completed_responses: int
176
+ bid_amount_cents: int
177
+ bonus_amount_cents: int
178
+ total_cost_cents: int
179
+ created_at: str
180
+ expires_at: Optional[str]
181
+ content_data: Dict[str, Any]
182
+ responses: List[QueryResponse]
183
+
184
+
185
+ class QueryList(TypedDict, total=False):
186
+ queries: List[Query]
187
+ total: int
188
+ limit: int
189
+ offset: int
190
+ has_more: bool
191
+
192
+
193
+ class RatingResult(TypedDict, total=False):
194
+ response_id: str
195
+ score: int
196
+ worker_new_quality_score: float
197
+ worker_new_rating_count: int
198
+ worker_suspended: bool
199
+ feedback_text: Optional[str]
200
+
201
+
202
+ class Rating(TypedDict, total=False):
203
+ id: str
204
+ response_id: str
205
+ score: int
206
+ feedback_text: Optional[str]
207
+ created_at: str
208
+
209
+
210
+ class AccountStats(TypedDict, total=False):
211
+ balance_cents: int
212
+ company_name: str
213
+ total_queries: int
214
+ active_queries: int
215
+ completed_queries: int
216
+ total_spent_cents: int
217
+ recent_queries: List[Query]
218
+
219
+
220
+ class BillingInfo(TypedDict, total=False):
221
+ balance_cents: int
222
+ company_name: str
223
+ transactions: List[Dict[str, Any]]
224
+ total: int
225
+ has_more: bool
@@ -0,0 +1,107 @@
1
+ import httpx
2
+
3
+ from getabrain import GetABrain, QUERY_TYPES, InsufficientBalanceError
4
+ import pytest
5
+
6
+
7
+ def client_with(handler):
8
+ return GetABrain(api_key="gab_k_x", api_secret="gab_s_y",
9
+ transport=httpx.MockTransport(handler))
10
+
11
+
12
+ def test_exports_present():
13
+ assert "ab_test" in QUERY_TYPES
14
+ gab = GetABrain(api_key="gab_k_x", api_secret="gab_s_y")
15
+ assert gab.queries is not None
16
+ assert gab.responses is not None
17
+ assert gab.account is not None
18
+ gab.close()
19
+
20
+
21
+ def test_context_manager_exposes_resources():
22
+ def handler(req: httpx.Request) -> httpx.Response:
23
+ return httpx.Response(200, json={"balance_cents": 0})
24
+
25
+ with client_with(handler) as gab:
26
+ assert gab.account is not None
27
+ assert gab.account.balance()["balance_cents"] == 0
28
+
29
+
30
+ def test_rate_omits_feedback_text_when_not_provided():
31
+ seen = {}
32
+
33
+ def handler(req: httpx.Request) -> httpx.Response:
34
+ seen["body"] = req.content.decode()
35
+ return httpx.Response(200, json={"response_id": "r1", "score": 5,
36
+ "worker_new_quality_score": 4.9, "worker_suspended": False})
37
+
38
+ gab = client_with(handler)
39
+ gab.responses.rate("q1", "r1", score=5)
40
+ assert '"score"' in seen["body"]
41
+ assert "feedback_text" not in seen["body"] # omitted, not sent as null
42
+
43
+
44
+ def test_account_balance_and_stats():
45
+ paths = []
46
+
47
+ def handler(req: httpx.Request) -> httpx.Response:
48
+ paths.append(req.url.path)
49
+ if req.url.path.endswith("/requestor/billing"):
50
+ return httpx.Response(200, json={"balance_cents": 900, "company_name": "X"})
51
+ return httpx.Response(200, json={"balance_cents": 900, "total_queries": 3})
52
+
53
+ gab = client_with(handler)
54
+ assert gab.account.balance()["balance_cents"] == 900
55
+ assert gab.account.stats()["total_queries"] == 3
56
+ assert "/api/v1/requestor/billing" in paths
57
+ assert "/api/v1/requestor/stats" in paths
58
+ gab.close()
59
+
60
+
61
+ def test_responses_rate_posts_to_the_rate_path():
62
+ seen = {}
63
+
64
+ def handler(req: httpx.Request) -> httpx.Response:
65
+ seen["method"] = req.method
66
+ seen["path"] = req.url.path
67
+ seen["body"] = req.content.decode()
68
+ return httpx.Response(200, json={"response_id": "r1", "score": 5,
69
+ "worker_new_quality_score": 4.9, "worker_suspended": False})
70
+
71
+ gab = client_with(handler)
72
+ out = gab.responses.rate("q1", "r1", score=5, feedback_text="great")
73
+ assert out["score"] == 5
74
+ assert seen["method"] == "POST"
75
+ assert seen["path"] == "/api/v1/requestor/queries/q1/responses/r1/rate"
76
+ assert '"score"' in seen["body"] and '"great"' in seen["body"]
77
+
78
+
79
+ def test_get_rating_gets_the_rate_path():
80
+ def handler(req: httpx.Request) -> httpx.Response:
81
+ assert req.method == "GET"
82
+ assert req.url.path == "/api/v1/requestor/queries/q1/responses/r1/rate"
83
+ return httpx.Response(200, json={"id": "rt1", "response_id": "r1", "score": 4})
84
+
85
+ gab = client_with(handler)
86
+ assert gab.responses.get_rating("q1", "r1")["score"] == 4
87
+
88
+
89
+ def test_create_query_flows_through_transport():
90
+ def handler(req: httpx.Request) -> httpx.Response:
91
+ return httpx.Response(201, json={"id": "q1", "status": "active", "total_cost_cents": 100})
92
+
93
+ gab = client_with(handler)
94
+ q = gab.queries.create(type="yes_no", title="Is it live?",
95
+ content_data={"question": "Is it live?"},
96
+ required_responses=2, bid_amount_cents=50)
97
+ assert q["id"] == "q1"
98
+
99
+
100
+ def test_insufficient_balance_raises():
101
+ def handler(req: httpx.Request) -> httpx.Response:
102
+ return httpx.Response(402, json={"error": "Payment Failed", "message": "Insufficient balance"})
103
+
104
+ gab = client_with(handler)
105
+ with pytest.raises(InsufficientBalanceError):
106
+ gab.queries.create(type="yes_no", title="x", content_data={"question": "x"},
107
+ required_responses=1, bid_amount_cents=5)
@@ -0,0 +1,55 @@
1
+ from getabrain.errors import (
2
+ GetABrainError, ValidationError, AuthError, InsufficientBalanceError,
3
+ ForbiddenError, NotFoundError, ConflictError, ServerError, RateLimitError,
4
+ TimeoutError, NetworkError, error_from_response,
5
+ )
6
+
7
+
8
+ def test_base_error_fields():
9
+ e = GetABrainError("boom", 418, "teapot", "req_1")
10
+ assert e.message == "boom"
11
+ assert e.status == 418
12
+ assert e.code == "teapot"
13
+ assert e.request_id == "req_1"
14
+ assert str(e) == "boom"
15
+ assert isinstance(e, Exception)
16
+
17
+
18
+ def test_error_from_response_maps_each_status():
19
+ body = {"error": "bad", "message": "nope"}
20
+ assert isinstance(error_from_response(400, body), ValidationError)
21
+ assert isinstance(error_from_response(401, body), AuthError)
22
+ assert isinstance(error_from_response(402, body), InsufficientBalanceError)
23
+ assert isinstance(error_from_response(403, body), ForbiddenError)
24
+ assert isinstance(error_from_response(404, body), NotFoundError)
25
+ assert isinstance(error_from_response(409, body), ConflictError)
26
+ assert isinstance(error_from_response(500, body), ServerError)
27
+ assert isinstance(error_from_response(503, body), ServerError)
28
+ # unmapped 4xx falls back to the base class
29
+ e = error_from_response(418, body)
30
+ assert type(e) is GetABrainError
31
+
32
+
33
+ def test_error_message_and_code_extracted():
34
+ e = error_from_response(400, {"error": "validation_error", "message": "title too short"})
35
+ assert e.message == "title too short"
36
+ assert e.code == "validation_error"
37
+ # missing body -> synthesized message
38
+ e2 = error_from_response(500, None)
39
+ assert e2.message == "HTTP 500"
40
+
41
+
42
+ def test_rate_limit_error_carries_retry_after_ms():
43
+ e = error_from_response(429, {"message": "slow down"}, retry_after_ms=5000)
44
+ assert isinstance(e, RateLimitError)
45
+ assert e.retry_after_ms == 5000
46
+
47
+
48
+ def test_timeout_and_network_errors_subclass_base():
49
+ assert isinstance(TimeoutError(), GetABrainError)
50
+ assert isinstance(NetworkError(), GetABrainError)
51
+ assert TimeoutError().code == "timeout"
52
+ assert NetworkError().code == "network"
53
+ # subclasses are catchable as the base
54
+ for cls in (ValidationError, AuthError, RateLimitError, ServerError):
55
+ assert issubclass(cls, GetABrainError)
@@ -0,0 +1,138 @@
1
+ import httpx
2
+ import pytest
3
+
4
+ from getabrain._http import build_transport
5
+ from getabrain.errors import AuthError, RateLimitError, ServerError, NetworkError, TimeoutError
6
+
7
+
8
+ def make(handler, **kw):
9
+ sleeps = []
10
+ _, request = build_transport(
11
+ api_key="gab_k_x", api_secret="gab_s_y",
12
+ transport=httpx.MockTransport(handler),
13
+ sleep=lambda s: sleeps.append(s),
14
+ **kw,
15
+ )
16
+ return request, sleeps
17
+
18
+
19
+ def test_sets_auth_headers_and_parses_json():
20
+ seen = {}
21
+
22
+ def handler(req: httpx.Request) -> httpx.Response:
23
+ seen["key"] = req.headers.get("x-api-key")
24
+ seen["secret"] = req.headers.get("x-api-secret")
25
+ seen["url"] = str(req.url)
26
+ return httpx.Response(200, json={"ok": True})
27
+
28
+ request, _ = make(handler)
29
+ out = request("GET", "/requestor/stats")
30
+ assert out == {"ok": True}
31
+ assert seen["key"] == "gab_k_x"
32
+ assert seen["secret"] == "gab_s_y"
33
+ assert seen["url"] == "https://www.getabrain.ai/api/v1/requestor/stats"
34
+
35
+
36
+ def test_post_body_and_query_params():
37
+ seen = {}
38
+
39
+ def handler(req: httpx.Request) -> httpx.Response:
40
+ seen["method"] = req.method
41
+ seen["content"] = req.content.decode()
42
+ seen["query"] = req.url.params.get("status")
43
+ return httpx.Response(201, json={"id": "q1"})
44
+
45
+ request, _ = make(handler)
46
+ request("POST", "/requestor/queries", body={"title": "hi"})
47
+ assert seen["method"] == "POST"
48
+ assert '"title"' in seen["content"]
49
+
50
+ request2, _ = make(handler)
51
+ request2("GET", "/requestor/queries", params={"status": "active", "limit": None})
52
+ assert seen["query"] == "active" # None params are dropped
53
+
54
+
55
+ def test_204_returns_none():
56
+ request, _ = make(lambda req: httpx.Response(204))
57
+ assert request("GET", "/x") is None
58
+
59
+
60
+ def test_maps_error_status_to_typed_error():
61
+ request, _ = make(lambda req: httpx.Response(401, json={"error": "auth", "message": "bad key"}))
62
+ with pytest.raises(AuthError):
63
+ request("GET", "/requestor/stats")
64
+
65
+
66
+ def test_get_retries_on_500_then_succeeds():
67
+ calls = {"n": 0}
68
+
69
+ def handler(req: httpx.Request) -> httpx.Response:
70
+ calls["n"] += 1
71
+ if calls["n"] < 3:
72
+ return httpx.Response(500, json={"error": "server"})
73
+ return httpx.Response(200, json={"ok": True})
74
+
75
+ request, sleeps = make(handler)
76
+ out = request("GET", "/requestor/stats")
77
+ assert out == {"ok": True}
78
+ assert calls["n"] == 3
79
+ assert len(sleeps) == 2 # two retries slept
80
+
81
+
82
+ def test_get_honors_retry_after_and_raises_after_max():
83
+ def handler(req: httpx.Request) -> httpx.Response:
84
+ return httpx.Response(429, headers={"Retry-After": "2"}, json={"error": "rate"})
85
+
86
+ request, sleeps = make(handler, max_retries=1)
87
+ with pytest.raises(RateLimitError) as ei:
88
+ request("GET", "/requestor/stats")
89
+ assert ei.value.retry_after_ms == 2000
90
+ assert sleeps == [2.0] # slept the Retry-After (2s) once before giving up
91
+
92
+
93
+ def test_post_does_not_retry_on_500():
94
+ calls = {"n": 0}
95
+
96
+ def handler(req: httpx.Request) -> httpx.Response:
97
+ calls["n"] += 1
98
+ return httpx.Response(500, json={"error": "server"})
99
+
100
+ request, sleeps = make(handler)
101
+ with pytest.raises(ServerError):
102
+ request("POST", "/requestor/queries", body={"x": 1})
103
+ assert calls["n"] == 1 # POST is never retried
104
+ assert sleeps == []
105
+
106
+
107
+ def test_transport_error_on_get_retries_then_raises_network():
108
+ def handler(req: httpx.Request) -> httpx.Response:
109
+ raise httpx.ConnectError("boom")
110
+
111
+ request, sleeps = make(handler, max_retries=2)
112
+ with pytest.raises(NetworkError):
113
+ request("GET", "/requestor/stats")
114
+ assert len(sleeps) == 2 # retried twice, then NetworkError
115
+
116
+
117
+ def test_timeout_raises_timeout_error_without_retry():
118
+ def handler(req: httpx.Request) -> httpx.Response:
119
+ raise httpx.ReadTimeout("slow")
120
+
121
+ request, sleeps = make(handler, max_retries=2)
122
+ with pytest.raises(TimeoutError):
123
+ request("GET", "/requestor/stats")
124
+ assert sleeps == [] # timeout is not retried
125
+
126
+
127
+ def test_get_backoff_values_without_retry_after():
128
+ calls = {"n": 0}
129
+
130
+ def handler(req: httpx.Request) -> httpx.Response:
131
+ calls["n"] += 1
132
+ if calls["n"] < 3:
133
+ return httpx.Response(500, json={"error": "server"})
134
+ return httpx.Response(200, json={"ok": True})
135
+
136
+ request, sleeps = make(handler)
137
+ assert request("GET", "/requestor/stats") == {"ok": True}
138
+ assert sleeps == [1.0, 2.0] # _backoff(1)=1.0, _backoff(2)=2.0
@@ -0,0 +1,105 @@
1
+ import pytest
2
+
3
+ from getabrain.resources.queries import QueriesResource
4
+ from getabrain.errors import TimeoutError
5
+
6
+
7
+ class Recorder:
8
+ def __init__(self, returns):
9
+ self.returns = list(returns)
10
+ self.calls = []
11
+
12
+ def __call__(self, method, path, *, body=None, params=None):
13
+ self.calls.append((method, path, body, params))
14
+ return self.returns.pop(0)
15
+
16
+
17
+ def test_create_posts_input():
18
+ rec = Recorder([{"id": "q1", "status": "active"}])
19
+ q = QueriesResource(rec).create(type="yes_no", title="ok?", content_data={"question": "ok?"},
20
+ required_responses=1, bid_amount_cents=5)
21
+ assert q["id"] == "q1"
22
+ method, path, body, _ = rec.calls[0]
23
+ assert method == "POST" and path == "/requestor/queries"
24
+ assert body["type"] == "yes_no" and body["required_responses"] == 1
25
+
26
+
27
+ def test_get_url_encodes_id():
28
+ rec = Recorder([{"id": "a/b"}])
29
+ QueriesResource(rec).get("a/b")
30
+ assert rec.calls[0][1] == "/requestor/queries/a%2Fb"
31
+
32
+
33
+ def test_list_passes_params():
34
+ rec = Recorder([{"queries": []}])
35
+ QueriesResource(rec).list(status="active", limit=10)
36
+ _, path, _, params = rec.calls[0]
37
+ assert path == "/requestor/queries"
38
+ assert params == {"status": "active", "limit": 10, "offset": None}
39
+
40
+
41
+ def test_cancel_puts_action():
42
+ rec = Recorder([{"success": True, "refund_amount_cents": 50}])
43
+ out = QueriesResource(rec).cancel("q1")
44
+ method, path, body, _ = rec.calls[0]
45
+ assert method == "PUT" and path == "/requestor/queries/q1"
46
+ assert body == {"action": "cancel"}
47
+ assert out["refund_amount_cents"] == 50
48
+
49
+
50
+ def test_wait_returns_when_enough_responses():
51
+ rec = Recorder([
52
+ {"status": "active", "completed_responses": 0, "required_responses": 2, "responses": []},
53
+ {"status": "active", "completed_responses": 2, "required_responses": 2,
54
+ "responses": [{"id": "r1"}, {"id": "r2"}]},
55
+ ])
56
+ sleeps = []
57
+ out = QueriesResource(rec).wait_for_responses(
58
+ "q1", min_responses=2, poll_interval_s=5, sleep=lambda s: sleeps.append(s),
59
+ )
60
+ assert len(out) == 2
61
+ assert sleeps == [5] # slept once between the two polls
62
+
63
+
64
+ def test_wait_returns_on_terminal_status():
65
+ rec = Recorder([{"status": "cancelled", "completed_responses": 0,
66
+ "required_responses": 5, "responses": []}])
67
+ out = QueriesResource(rec).wait_for_responses("q1", min_responses=5, sleep=lambda s: None)
68
+ assert out == []
69
+
70
+
71
+ def test_wait_uses_len_responses_when_count_absent():
72
+ rec = Recorder([
73
+ {"status": "active", "required_responses": 2, "responses": [{"id": "r1"}, {"id": "r2"}]},
74
+ ])
75
+ out = QueriesResource(rec).wait_for_responses("q1", min_responses=2, sleep=lambda s: None)
76
+ assert len(out) == 2 # no completed_responses key -> falls back to len(responses)
77
+
78
+
79
+ import pytest as _pytest
80
+
81
+
82
+ @_pytest.mark.parametrize("terminal", ["completed", "cancelled", "failed", "expired"])
83
+ def test_wait_returns_on_each_terminal_status(terminal):
84
+ rec = Recorder([{"status": terminal, "completed_responses": 0,
85
+ "required_responses": 5, "responses": []}])
86
+ out = QueriesResource(rec).wait_for_responses("q1", min_responses=5, sleep=lambda s: None)
87
+ assert out == []
88
+
89
+
90
+ def test_wait_raises_timeout_when_deadline_passes():
91
+ rec = Recorder([
92
+ {"status": "active", "completed_responses": 0, "required_responses": 9, "responses": []}
93
+ for _ in range(10)
94
+ ])
95
+ clock = {"t": 0.0}
96
+
97
+ def now():
98
+ clock["t"] += 10.0 # each call jumps 10s
99
+ return clock["t"]
100
+
101
+ with pytest.raises(TimeoutError):
102
+ QueriesResource(rec).wait_for_responses(
103
+ "q1", min_responses=9, timeout_s=5, poll_interval_s=1,
104
+ sleep=lambda s: None, now=now,
105
+ )
@@ -0,0 +1,28 @@
1
+ from getabrain.types import QUERY_TYPES, Query, AbTestContent, RatingResult
2
+
3
+
4
+ def test_query_types_has_the_thirteen():
5
+ assert len(QUERY_TYPES) == 13
6
+ for t in (
7
+ "text", "multiple_choice", "rating_scale", "image_comparison", "ranking",
8
+ "yes_no", "sentiment", "image_selection", "free_form_text", "video_review",
9
+ "audio_review", "image_analysis", "ab_test",
10
+ ):
11
+ assert t in QUERY_TYPES
12
+
13
+
14
+ def test_typeddicts_are_constructible_as_plain_dicts():
15
+ # TypedDicts are dicts at runtime; this just guards the names exist + import cleanly.
16
+ content: AbTestContent = {
17
+ "question": "Which is better?",
18
+ "variant_a": {"description": "A"},
19
+ "variant_b": {"description": "B"},
20
+ }
21
+ assert content["variant_a"]["description"] == "A"
22
+ q: Query = {"id": "q1", "type": "ab_test", "title": "t", "status": "active",
23
+ "required_responses": 1, "bid_amount_cents": 5, "total_cost_cents": 5,
24
+ "created_at": "now"}
25
+ assert q["status"] == "active"
26
+ r: RatingResult = {"response_id": "r1", "score": 5, "worker_new_quality_score": 4.8,
27
+ "worker_suspended": False}
28
+ assert r["score"] == 5