qubac 1.0.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.
- qubac-1.0.0/PKG-INFO +55 -0
- qubac-1.0.0/README.md +24 -0
- qubac-1.0.0/pyproject.toml +53 -0
- qubac-1.0.0/qubac/__init__.py +32 -0
- qubac-1.0.0/qubac/_client.py +106 -0
- qubac-1.0.0/qubac/_exceptions.py +71 -0
- qubac-1.0.0/qubac/_http.py +170 -0
- qubac-1.0.0/qubac/py.typed +0 -0
- qubac-1.0.0/qubac/resources/__init__.py +2 -0
- qubac-1.0.0/qubac/resources/tasks.py +188 -0
- qubac-1.0.0/qubac/types/__init__.py +69 -0
- qubac-1.0.0/tests/__init__.py +0 -0
- qubac-1.0.0/tests/test_client.py +196 -0
qubac-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: qubac
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: The official Python SDK for SAM by Qubac
|
|
5
|
+
Project-URL: Homepage, https://qubac.com
|
|
6
|
+
Project-URL: Documentation, https://docs.qubac.com
|
|
7
|
+
Project-URL: Repository, https://github.com/qubac/qubac-python
|
|
8
|
+
Author-email: Qubac <sdk@qubac.com>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: agent,ai,autonomous,qubac,sam
|
|
11
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
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: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.9
|
|
21
|
+
Requires-Dist: anyio>=3.0.0
|
|
22
|
+
Requires-Dist: httpx>=0.25.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: black; extra == 'dev'
|
|
25
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: respx>=0.20; extra == 'dev'
|
|
29
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# Qubac Python SDK
|
|
33
|
+
|
|
34
|
+
The official Python library for SAM by Qubac.
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
import qubac
|
|
38
|
+
|
|
39
|
+
client = qubac.Client(api_key="sk-...")
|
|
40
|
+
response = client.tasks.create(
|
|
41
|
+
model="sam-1",
|
|
42
|
+
task="what is the speed of light"
|
|
43
|
+
)
|
|
44
|
+
print(response.output)
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Installation
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
pip install qubac
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Docs
|
|
54
|
+
|
|
55
|
+
https://docs.qubac.com
|
qubac-1.0.0/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Qubac Python SDK
|
|
2
|
+
|
|
3
|
+
The official Python library for SAM by Qubac.
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import qubac
|
|
7
|
+
|
|
8
|
+
client = qubac.Client(api_key="sk-...")
|
|
9
|
+
response = client.tasks.create(
|
|
10
|
+
model="sam-1",
|
|
11
|
+
task="what is the speed of light"
|
|
12
|
+
)
|
|
13
|
+
print(response.output)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install qubac
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Docs
|
|
23
|
+
|
|
24
|
+
https://docs.qubac.com
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "qubac"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "The official Python SDK for SAM by Qubac"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "MIT" }
|
|
11
|
+
authors = [{ name = "Qubac", email = "sdk@qubac.com" }]
|
|
12
|
+
keywords = ["qubac", "sam", "ai", "agent", "autonomous"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 5 - Production/Stable",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.9",
|
|
19
|
+
"Programming Language :: Python :: 3.10",
|
|
20
|
+
"Programming Language :: Python :: 3.11",
|
|
21
|
+
"Programming Language :: Python :: 3.12",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
requires-python = ">=3.9"
|
|
25
|
+
dependencies = [
|
|
26
|
+
"httpx>=0.25.0",
|
|
27
|
+
"anyio>=3.0.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
[project.optional-dependencies]
|
|
31
|
+
dev = [
|
|
32
|
+
"pytest>=7.0",
|
|
33
|
+
"pytest-asyncio>=0.21",
|
|
34
|
+
"respx>=0.20",
|
|
35
|
+
"black",
|
|
36
|
+
"mypy",
|
|
37
|
+
"ruff",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://qubac.com"
|
|
42
|
+
Documentation = "https://docs.qubac.com"
|
|
43
|
+
Repository = "https://github.com/qubac/qubac-python"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["qubac"]
|
|
47
|
+
|
|
48
|
+
[tool.mypy]
|
|
49
|
+
strict = true
|
|
50
|
+
python_version = "3.9"
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Qubac SDK — Python client for SAM
|
|
3
|
+
|
|
4
|
+
Install: pip install qubac
|
|
5
|
+
Docs: https://docs.qubac.com
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
import qubac
|
|
9
|
+
client = qubac.Client(api_key="sk-...")
|
|
10
|
+
result = client.tasks.create(model="sam-1", task="what is 2+2")
|
|
11
|
+
print(result.output)
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
from ._client import AsyncClient, Client
|
|
15
|
+
from ._exceptions import (
|
|
16
|
+
APIConnectionError, APIError, APITimeoutError, ApprovalRequiredError,
|
|
17
|
+
AuthenticationError, InternalServerError, NotFoundError,
|
|
18
|
+
PermissionDeniedError, QubacError, RateLimitError,
|
|
19
|
+
TaskFailedError, TaskTimeoutError,
|
|
20
|
+
)
|
|
21
|
+
from .types import Model, StreamEvent, TaskAsyncResponse, TaskResponse
|
|
22
|
+
|
|
23
|
+
__version__ = "1.0.0"
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Client", "AsyncClient",
|
|
26
|
+
"TaskResponse", "TaskAsyncResponse", "StreamEvent", "Model",
|
|
27
|
+
"QubacError", "APIError", "APIConnectionError", "APITimeoutError",
|
|
28
|
+
"AuthenticationError", "PermissionDeniedError", "NotFoundError",
|
|
29
|
+
"RateLimitError", "InternalServerError",
|
|
30
|
+
"TaskTimeoutError", "TaskFailedError", "ApprovalRequiredError",
|
|
31
|
+
"__version__",
|
|
32
|
+
]
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import os
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
from ._exceptions import AuthenticationError
|
|
5
|
+
from ._http import AsyncHttpClient, SyncHttpClient
|
|
6
|
+
from .resources.tasks import AsyncTasks, Tasks
|
|
7
|
+
|
|
8
|
+
__all__ = ["Client", "AsyncClient"]
|
|
9
|
+
|
|
10
|
+
_DEFAULT_BASE_URL = "https://api.qubac.com"
|
|
11
|
+
_DEFAULT_TIMEOUT = 130.0
|
|
12
|
+
_DEFAULT_MAX_RETRIES = 2
|
|
13
|
+
|
|
14
|
+
class Client:
|
|
15
|
+
"""
|
|
16
|
+
Synchronous Qubac client.
|
|
17
|
+
|
|
18
|
+
Usage:
|
|
19
|
+
import qubac
|
|
20
|
+
client = qubac.Client(api_key="sk-...")
|
|
21
|
+
result = client.tasks.create(model="sam-1", task="what is 2+2")
|
|
22
|
+
print(result.output)
|
|
23
|
+
"""
|
|
24
|
+
tasks: Tasks
|
|
25
|
+
|
|
26
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = _DEFAULT_BASE_URL,
|
|
27
|
+
timeout: float = _DEFAULT_TIMEOUT, max_retries: int = _DEFAULT_MAX_RETRIES) -> None:
|
|
28
|
+
resolved = api_key or os.environ.get("QUBAC_API_KEY", "")
|
|
29
|
+
if not resolved:
|
|
30
|
+
raise AuthenticationError(
|
|
31
|
+
"No API key provided. Pass api_key= or set QUBAC_API_KEY.\n"
|
|
32
|
+
"Get your key at https://console.qubac.com/configure"
|
|
33
|
+
)
|
|
34
|
+
self._http = SyncHttpClient(base_url=base_url, api_key=resolved,
|
|
35
|
+
timeout=timeout, max_retries=max_retries)
|
|
36
|
+
self.tasks = Tasks(self._http)
|
|
37
|
+
|
|
38
|
+
def close(self) -> None:
|
|
39
|
+
self._http.close()
|
|
40
|
+
|
|
41
|
+
def __enter__(self) -> "Client":
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
def __exit__(self, *_: Any) -> None:
|
|
45
|
+
self.close()
|
|
46
|
+
|
|
47
|
+
def __repr__(self) -> str:
|
|
48
|
+
return f"qubac.Client(base_url={self._http._base_url!r})"
|
|
49
|
+
|
|
50
|
+
def with_options(self, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
51
|
+
timeout: Optional[float] = None, max_retries: Optional[int] = None) -> "Client":
|
|
52
|
+
"""Return a new Client with overridden options."""
|
|
53
|
+
return Client(
|
|
54
|
+
api_key=api_key or self._http._api_key,
|
|
55
|
+
base_url=base_url or self._http._base_url,
|
|
56
|
+
timeout=timeout if timeout is not None else self._http._timeout,
|
|
57
|
+
max_retries=max_retries if max_retries is not None else self._http._max_retries,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
class AsyncClient:
|
|
61
|
+
"""
|
|
62
|
+
Asynchronous Qubac client.
|
|
63
|
+
|
|
64
|
+
Usage:
|
|
65
|
+
import asyncio, qubac
|
|
66
|
+
async def main():
|
|
67
|
+
async with qubac.AsyncClient(api_key="sk-...") as client:
|
|
68
|
+
result = await client.tasks.create(model="sam-1", task="what is 2+2")
|
|
69
|
+
print(result.output)
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
"""
|
|
72
|
+
tasks: AsyncTasks
|
|
73
|
+
|
|
74
|
+
def __init__(self, api_key: Optional[str] = None, *, base_url: str = _DEFAULT_BASE_URL,
|
|
75
|
+
timeout: float = _DEFAULT_TIMEOUT, max_retries: int = _DEFAULT_MAX_RETRIES) -> None:
|
|
76
|
+
resolved = api_key or os.environ.get("QUBAC_API_KEY", "")
|
|
77
|
+
if not resolved:
|
|
78
|
+
raise AuthenticationError(
|
|
79
|
+
"No API key provided. Pass api_key= or set QUBAC_API_KEY.\n"
|
|
80
|
+
"Get your key at https://console.qubac.com/configure"
|
|
81
|
+
)
|
|
82
|
+
self._http = AsyncHttpClient(base_url=base_url, api_key=resolved,
|
|
83
|
+
timeout=timeout, max_retries=max_retries)
|
|
84
|
+
self.tasks = AsyncTasks(self._http)
|
|
85
|
+
|
|
86
|
+
async def aclose(self) -> None:
|
|
87
|
+
await self._http.aclose()
|
|
88
|
+
|
|
89
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
90
|
+
return self
|
|
91
|
+
|
|
92
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
93
|
+
await self.aclose()
|
|
94
|
+
|
|
95
|
+
def __repr__(self) -> str:
|
|
96
|
+
return f"qubac.AsyncClient(base_url={self._http._base_url!r})"
|
|
97
|
+
|
|
98
|
+
def with_options(self, *, api_key: Optional[str] = None, base_url: Optional[str] = None,
|
|
99
|
+
timeout: Optional[float] = None, max_retries: Optional[int] = None) -> "AsyncClient":
|
|
100
|
+
"""Return a new AsyncClient with overridden options."""
|
|
101
|
+
return AsyncClient(
|
|
102
|
+
api_key=api_key or self._http._api_key,
|
|
103
|
+
base_url=base_url or self._http._base_url,
|
|
104
|
+
timeout=timeout if timeout is not None else self._http._timeout,
|
|
105
|
+
max_retries=max_retries if max_retries is not None else self._http._max_retries,
|
|
106
|
+
)
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Dict, Optional
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"QubacError", "APIError", "APIConnectionError", "APITimeoutError",
|
|
6
|
+
"AuthenticationError", "PermissionDeniedError", "NotFoundError",
|
|
7
|
+
"RateLimitError", "InternalServerError",
|
|
8
|
+
"TaskTimeoutError", "TaskFailedError", "ApprovalRequiredError",
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
class QubacError(Exception):
|
|
12
|
+
def __init__(self, message: str, *, status_code: Optional[int] = None) -> None:
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.message = message
|
|
15
|
+
self.status_code = status_code
|
|
16
|
+
|
|
17
|
+
class APIError(QubacError): pass
|
|
18
|
+
|
|
19
|
+
class AuthenticationError(APIError):
|
|
20
|
+
def __init__(self, message: str = "Authentication failed. Check your API key.") -> None:
|
|
21
|
+
super().__init__(message, status_code=401)
|
|
22
|
+
|
|
23
|
+
class PermissionDeniedError(APIError):
|
|
24
|
+
def __init__(self, message: str) -> None:
|
|
25
|
+
super().__init__(message, status_code=403)
|
|
26
|
+
|
|
27
|
+
class NotFoundError(APIError):
|
|
28
|
+
def __init__(self, message: str) -> None:
|
|
29
|
+
super().__init__(message, status_code=404)
|
|
30
|
+
|
|
31
|
+
class RateLimitError(APIError):
|
|
32
|
+
def __init__(self, message: str, *, reset_at: Optional[str] = None, retry_after: Optional[int] = None) -> None:
|
|
33
|
+
super().__init__(message, status_code=429)
|
|
34
|
+
self.reset_at = reset_at
|
|
35
|
+
self.retry_after = retry_after
|
|
36
|
+
|
|
37
|
+
class InternalServerError(APIError):
|
|
38
|
+
def __init__(self, message: str, *, status_code: int = 500) -> None:
|
|
39
|
+
super().__init__(message, status_code=status_code)
|
|
40
|
+
|
|
41
|
+
class APIConnectionError(QubacError):
|
|
42
|
+
def __init__(self, message: str = "Could not connect to the Qubac API.") -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
|
|
45
|
+
class APITimeoutError(QubacError):
|
|
46
|
+
def __init__(self, timeout: float) -> None:
|
|
47
|
+
super().__init__(f"Request timed out after {timeout}s.")
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
|
|
50
|
+
class TaskTimeoutError(QubacError):
|
|
51
|
+
def __init__(self, task_id: str, timeout: int) -> None:
|
|
52
|
+
super().__init__(
|
|
53
|
+
f"Task '{task_id}' did not complete within {timeout}s. "
|
|
54
|
+
f"Use client.tasks.get('{task_id}') to poll for the result."
|
|
55
|
+
)
|
|
56
|
+
self.task_id = task_id
|
|
57
|
+
self.timeout = timeout
|
|
58
|
+
|
|
59
|
+
class TaskFailedError(QubacError):
|
|
60
|
+
def __init__(self, task_id: str, reason: Optional[str] = None) -> None:
|
|
61
|
+
super().__init__(reason or f"Task '{task_id}' failed during execution.")
|
|
62
|
+
self.task_id = task_id
|
|
63
|
+
self.reason = reason
|
|
64
|
+
|
|
65
|
+
class ApprovalRequiredError(QubacError):
|
|
66
|
+
def __init__(self, task_id: str, detail: Optional[str] = None) -> None:
|
|
67
|
+
super().__init__(
|
|
68
|
+
detail or f"Task '{task_id}' requires approval. "
|
|
69
|
+
"Review it at https://console.qubac.com/approvals"
|
|
70
|
+
)
|
|
71
|
+
self.task_id = task_id
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio, json, logging, random, time
|
|
3
|
+
from typing import Any, AsyncGenerator, Dict, Generator, Optional
|
|
4
|
+
import httpx
|
|
5
|
+
from ._exceptions import (
|
|
6
|
+
APIConnectionError, APIError, APITimeoutError, AuthenticationError,
|
|
7
|
+
InternalServerError, NotFoundError, PermissionDeniedError, RateLimitError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
log = logging.getLogger("qubac")
|
|
11
|
+
_SDK_VERSION = "1.0.0"
|
|
12
|
+
_RETRYABLE = {429, 500, 502, 503, 504}
|
|
13
|
+
_BACKOFF_BASE = 0.5
|
|
14
|
+
_BACKOFF_MAX = 8.0
|
|
15
|
+
|
|
16
|
+
def _user_agent() -> str:
|
|
17
|
+
import platform
|
|
18
|
+
return f"qubac-python/{_SDK_VERSION} Python/{platform.python_version()}"
|
|
19
|
+
|
|
20
|
+
def _headers(api_key: str) -> Dict[str, str]:
|
|
21
|
+
return {
|
|
22
|
+
"Authorization": f"Bearer {api_key}",
|
|
23
|
+
"Content-Type": "application/json",
|
|
24
|
+
"Accept": "application/json",
|
|
25
|
+
"User-Agent": _user_agent(),
|
|
26
|
+
"X-Qubac-SDK-Version": _SDK_VERSION,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
def _backoff(attempt: int) -> float:
|
|
30
|
+
delay = min(_BACKOFF_BASE * (2 ** attempt), _BACKOFF_MAX)
|
|
31
|
+
return delay * (0.5 + random.random() * 0.5)
|
|
32
|
+
|
|
33
|
+
def _raise(status: int, body: Dict[str, Any]) -> None:
|
|
34
|
+
detail = str(body.get("detail") or body.get("message") or "Unknown error")
|
|
35
|
+
if status == 401: raise AuthenticationError(f"Authentication failed: {detail}")
|
|
36
|
+
if status == 403: raise PermissionDeniedError(f"Permission denied: {detail}")
|
|
37
|
+
if status == 404: raise NotFoundError(f"Not found: {detail}")
|
|
38
|
+
if status in (429, 402):
|
|
39
|
+
raise RateLimitError(f"Rate limit: {detail}", reset_at=body.get("reset_at"))
|
|
40
|
+
if 500 <= status < 600:
|
|
41
|
+
raise InternalServerError(f"Server error {status}: {detail}", status_code=status)
|
|
42
|
+
raise APIError(f"API error {status}: {detail}", status_code=status)
|
|
43
|
+
|
|
44
|
+
class SyncHttpClient:
|
|
45
|
+
def __init__(self, base_url: str, api_key: str, timeout: float, max_retries: int) -> None:
|
|
46
|
+
self._base_url = base_url.rstrip("/")
|
|
47
|
+
self._api_key = api_key
|
|
48
|
+
self._timeout = timeout
|
|
49
|
+
self._max_retries = max_retries
|
|
50
|
+
self._client = httpx.Client(
|
|
51
|
+
base_url=self._base_url,
|
|
52
|
+
headers=_headers(api_key),
|
|
53
|
+
timeout=httpx.Timeout(timeout),
|
|
54
|
+
follow_redirects=True,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
def close(self) -> None:
|
|
58
|
+
self._client.close()
|
|
59
|
+
|
|
60
|
+
def __enter__(self) -> "SyncHttpClient":
|
|
61
|
+
return self
|
|
62
|
+
|
|
63
|
+
def __exit__(self, *_: Any) -> None:
|
|
64
|
+
self.close()
|
|
65
|
+
|
|
66
|
+
def request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
67
|
+
kwargs: Dict[str, Any] = {"json": json_body}
|
|
68
|
+
if timeout is not None:
|
|
69
|
+
kwargs["timeout"] = httpx.Timeout(timeout)
|
|
70
|
+
for attempt in range(self._max_retries + 1):
|
|
71
|
+
try:
|
|
72
|
+
resp = self._client.request(method, path, **kwargs)
|
|
73
|
+
if resp.status_code in _RETRYABLE and attempt < self._max_retries:
|
|
74
|
+
time.sleep(_backoff(attempt))
|
|
75
|
+
continue
|
|
76
|
+
try: body = resp.json()
|
|
77
|
+
except Exception: body = {}
|
|
78
|
+
if not resp.is_success:
|
|
79
|
+
_raise(resp.status_code, body)
|
|
80
|
+
return body
|
|
81
|
+
except httpx.TimeoutException as exc:
|
|
82
|
+
if attempt < self._max_retries: time.sleep(_backoff(attempt)); continue
|
|
83
|
+
raise APITimeoutError(timeout or self._timeout) from exc
|
|
84
|
+
except httpx.NetworkError as exc:
|
|
85
|
+
if attempt < self._max_retries: time.sleep(_backoff(attempt)); continue
|
|
86
|
+
raise APIConnectionError(f"Network error: {exc}") from exc
|
|
87
|
+
raise APIConnectionError("Request failed after retries")
|
|
88
|
+
|
|
89
|
+
def stream_sse(self, path: str, *, timeout: Optional[float] = None) -> Generator[Dict[str, Any], None, None]:
|
|
90
|
+
hdrs = {**_headers(self._api_key), "Accept": "text/event-stream"}
|
|
91
|
+
t = httpx.Timeout(timeout or self._timeout)
|
|
92
|
+
with self._client.stream("GET", path, headers=hdrs, timeout=t) as resp:
|
|
93
|
+
if not resp.is_success:
|
|
94
|
+
try: body = resp.json()
|
|
95
|
+
except Exception: body = {}
|
|
96
|
+
_raise(resp.status_code, body)
|
|
97
|
+
buf = ""
|
|
98
|
+
for chunk in resp.iter_text():
|
|
99
|
+
buf += chunk
|
|
100
|
+
while "\n" in buf:
|
|
101
|
+
line, buf = buf.split("\n", 1)
|
|
102
|
+
if line.strip().startswith("data:"):
|
|
103
|
+
raw = line.strip()[5:].strip()
|
|
104
|
+
if raw:
|
|
105
|
+
try: yield json.loads(raw)
|
|
106
|
+
except json.JSONDecodeError: pass
|
|
107
|
+
|
|
108
|
+
class AsyncHttpClient:
|
|
109
|
+
def __init__(self, base_url: str, api_key: str, timeout: float, max_retries: int) -> None:
|
|
110
|
+
self._base_url = base_url.rstrip("/")
|
|
111
|
+
self._api_key = api_key
|
|
112
|
+
self._timeout = timeout
|
|
113
|
+
self._max_retries = max_retries
|
|
114
|
+
self._client = httpx.AsyncClient(
|
|
115
|
+
base_url=self._base_url,
|
|
116
|
+
headers=_headers(api_key),
|
|
117
|
+
timeout=httpx.Timeout(timeout),
|
|
118
|
+
follow_redirects=True,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
async def aclose(self) -> None:
|
|
122
|
+
await self._client.aclose()
|
|
123
|
+
|
|
124
|
+
async def __aenter__(self) -> "AsyncHttpClient":
|
|
125
|
+
return self
|
|
126
|
+
|
|
127
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
128
|
+
await self.aclose()
|
|
129
|
+
|
|
130
|
+
async def request(self, method: str, path: str, *, json_body: Optional[Dict[str, Any]] = None, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
131
|
+
kwargs: Dict[str, Any] = {"json": json_body}
|
|
132
|
+
if timeout is not None:
|
|
133
|
+
kwargs["timeout"] = httpx.Timeout(timeout)
|
|
134
|
+
for attempt in range(self._max_retries + 1):
|
|
135
|
+
try:
|
|
136
|
+
resp = await self._client.request(method, path, **kwargs)
|
|
137
|
+
if resp.status_code in _RETRYABLE and attempt < self._max_retries:
|
|
138
|
+
await asyncio.sleep(_backoff(attempt))
|
|
139
|
+
continue
|
|
140
|
+
try: body = resp.json()
|
|
141
|
+
except Exception: body = {}
|
|
142
|
+
if not resp.is_success:
|
|
143
|
+
_raise(resp.status_code, body)
|
|
144
|
+
return body
|
|
145
|
+
except httpx.TimeoutException as exc:
|
|
146
|
+
if attempt < self._max_retries: await asyncio.sleep(_backoff(attempt)); continue
|
|
147
|
+
raise APITimeoutError(timeout or self._timeout) from exc
|
|
148
|
+
except httpx.NetworkError as exc:
|
|
149
|
+
if attempt < self._max_retries: await asyncio.sleep(_backoff(attempt)); continue
|
|
150
|
+
raise APIConnectionError(f"Network error: {exc}") from exc
|
|
151
|
+
raise APIConnectionError("Request failed after retries")
|
|
152
|
+
|
|
153
|
+
async def stream_sse(self, path: str, *, timeout: Optional[float] = None) -> AsyncGenerator[Dict[str, Any], None]:
|
|
154
|
+
hdrs = {**_headers(self._api_key), "Accept": "text/event-stream"}
|
|
155
|
+
t = httpx.Timeout(timeout or self._timeout)
|
|
156
|
+
async with self._client.stream("GET", path, headers=hdrs, timeout=t) as resp:
|
|
157
|
+
if not resp.is_success:
|
|
158
|
+
try: body = resp.json()
|
|
159
|
+
except Exception: body = {}
|
|
160
|
+
_raise(resp.status_code, body)
|
|
161
|
+
buf = ""
|
|
162
|
+
async for chunk in resp.aiter_text():
|
|
163
|
+
buf += chunk
|
|
164
|
+
while "\n" in buf:
|
|
165
|
+
line, buf = buf.split("\n", 1)
|
|
166
|
+
if line.strip().startswith("data:"):
|
|
167
|
+
raw = line.strip()[5:].strip()
|
|
168
|
+
if raw:
|
|
169
|
+
try: yield json.loads(raw)
|
|
170
|
+
except json.JSONDecodeError: pass
|
|
File without changes
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, AsyncGenerator, Dict, Iterator, List, Optional
|
|
3
|
+
from .._exceptions import TaskFailedError, TaskTimeoutError
|
|
4
|
+
from .._http import AsyncHttpClient, SyncHttpClient
|
|
5
|
+
from ..types import Model, StreamEvent, TaskAsyncResponse, TaskResponse
|
|
6
|
+
|
|
7
|
+
__all__ = ["Tasks", "AsyncTasks", "SyncStreamContext", "AsyncStreamContext"]
|
|
8
|
+
|
|
9
|
+
_DEFAULT_MODEL = "sam-1"
|
|
10
|
+
_DEFAULT_TIMEOUT = 120
|
|
11
|
+
_DEFAULT_PRIORITY = "normal"
|
|
12
|
+
|
|
13
|
+
def _parse(data: Dict[str, Any]) -> TaskResponse:
|
|
14
|
+
return TaskResponse(
|
|
15
|
+
id=data["id"], model=data.get("model", _DEFAULT_MODEL),
|
|
16
|
+
status=data.get("status", "unknown"),
|
|
17
|
+
output=data.get("output"), nodes_used=data.get("nodes_used", 0),
|
|
18
|
+
execution_ms=data.get("execution_ms"), created_at=data.get("created_at", ""),
|
|
19
|
+
metadata=data.get("metadata"), error=data.get("error"),
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def _parse_event(data: Dict[str, Any], task_id: str) -> StreamEvent:
|
|
23
|
+
return StreamEvent(
|
|
24
|
+
type=data.get("type", "task.update"), task_id=data.get("task_id", task_id),
|
|
25
|
+
summary=data.get("summary", ""), timestamp=data.get("timestamp"),
|
|
26
|
+
node=data.get("node"), output=data.get("output"),
|
|
27
|
+
nodes_used=data.get("nodes_used"), error=data.get("error"), raw=data,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
def _create_body(task: str, model: str, priority: str, max_nodes: Optional[int], timeout: int, metadata: Optional[Dict]) -> Dict[str, Any]:
|
|
31
|
+
b: Dict[str, Any] = {"task": task, "model": model, "priority": priority, "timeout": timeout}
|
|
32
|
+
if max_nodes is not None: b["max_nodes"] = max_nodes
|
|
33
|
+
if metadata is not None: b["metadata"] = metadata
|
|
34
|
+
return b
|
|
35
|
+
|
|
36
|
+
def _submit_body(task: str, model: str, priority: str, max_nodes: Optional[int], metadata: Optional[Dict]) -> Dict[str, Any]:
|
|
37
|
+
b: Dict[str, Any] = {"task": task, "model": model, "priority": priority}
|
|
38
|
+
if max_nodes is not None: b["max_nodes"] = max_nodes
|
|
39
|
+
if metadata is not None: b["metadata"] = metadata
|
|
40
|
+
return b
|
|
41
|
+
|
|
42
|
+
class SyncStreamContext:
|
|
43
|
+
def __init__(self, http: SyncHttpClient, task_id: str, timeout: Optional[float] = None) -> None:
|
|
44
|
+
self._http = http
|
|
45
|
+
self._task_id = task_id
|
|
46
|
+
self._timeout = timeout
|
|
47
|
+
self._final: Optional[TaskResponse] = None
|
|
48
|
+
|
|
49
|
+
def __enter__(self) -> "SyncStreamContext":
|
|
50
|
+
return self
|
|
51
|
+
|
|
52
|
+
def __exit__(self, *_: Any) -> None:
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
def __iter__(self) -> Iterator[StreamEvent]:
|
|
56
|
+
for data in self._http.stream_sse(f"/v1/sam/tasks/{self._task_id}/stream", timeout=self._timeout):
|
|
57
|
+
evt = _parse_event(data, self._task_id)
|
|
58
|
+
if evt.type == "stream.end": break
|
|
59
|
+
yield evt
|
|
60
|
+
if evt.type == "task.completed":
|
|
61
|
+
self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="completed",
|
|
62
|
+
output=evt.output, nodes_used=evt.nodes_used or 0,
|
|
63
|
+
execution_ms=None, created_at=evt.timestamp or "")
|
|
64
|
+
break
|
|
65
|
+
if evt.type == "task.failed":
|
|
66
|
+
self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="failed",
|
|
67
|
+
output=None, nodes_used=0, execution_ms=None,
|
|
68
|
+
created_at=evt.timestamp or "", error=evt.error)
|
|
69
|
+
break
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def final_response(self) -> Optional[TaskResponse]:
|
|
73
|
+
return self._final
|
|
74
|
+
|
|
75
|
+
class AsyncStreamContext:
|
|
76
|
+
def __init__(self, http: AsyncHttpClient, task_id: str, timeout: Optional[float] = None) -> None:
|
|
77
|
+
self._http = http
|
|
78
|
+
self._task_id = task_id
|
|
79
|
+
self._timeout = timeout
|
|
80
|
+
self._final: Optional[TaskResponse] = None
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "AsyncStreamContext":
|
|
83
|
+
return self
|
|
84
|
+
|
|
85
|
+
async def __aexit__(self, *_: Any) -> None:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
async def __aiter__(self):
|
|
89
|
+
async for data in self._http.stream_sse(f"/v1/sam/tasks/{self._task_id}/stream", timeout=self._timeout):
|
|
90
|
+
evt = _parse_event(data, self._task_id)
|
|
91
|
+
if evt.type == "stream.end": break
|
|
92
|
+
yield evt
|
|
93
|
+
if evt.type == "task.completed":
|
|
94
|
+
self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="completed",
|
|
95
|
+
output=evt.output, nodes_used=evt.nodes_used or 0,
|
|
96
|
+
execution_ms=None, created_at=evt.timestamp or "")
|
|
97
|
+
break
|
|
98
|
+
if evt.type == "task.failed":
|
|
99
|
+
self._final = TaskResponse(id=evt.task_id, model=_DEFAULT_MODEL, status="failed",
|
|
100
|
+
output=None, nodes_used=0, execution_ms=None,
|
|
101
|
+
created_at=evt.timestamp or "", error=evt.error)
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
@property
|
|
105
|
+
def final_response(self) -> Optional[TaskResponse]:
|
|
106
|
+
return self._final
|
|
107
|
+
|
|
108
|
+
class Tasks:
|
|
109
|
+
"""Sync tasks resource. Access via client.tasks."""
|
|
110
|
+
def __init__(self, http: SyncHttpClient) -> None:
|
|
111
|
+
self._http = http
|
|
112
|
+
|
|
113
|
+
def create(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
|
|
114
|
+
max_nodes: Optional[int] = None, timeout: int = _DEFAULT_TIMEOUT,
|
|
115
|
+
metadata: Optional[Dict[str, Any]] = None, request_timeout: Optional[float] = None) -> TaskResponse:
|
|
116
|
+
"""Create and execute a SAM task synchronously. Waits for completion."""
|
|
117
|
+
data = self._http.request("POST", "/v1/sam/tasks",
|
|
118
|
+
json_body=_create_body(task, model, priority, max_nodes, timeout, metadata),
|
|
119
|
+
timeout=request_timeout)
|
|
120
|
+
resp = _parse(data)
|
|
121
|
+
if resp.status == "running": raise TaskTimeoutError(resp.id, timeout)
|
|
122
|
+
if resp.status == "failed": raise TaskFailedError(resp.id, resp.error)
|
|
123
|
+
return resp
|
|
124
|
+
|
|
125
|
+
def submit(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
|
|
126
|
+
max_nodes: Optional[int] = None, metadata: Optional[Dict[str, Any]] = None,
|
|
127
|
+
request_timeout: Optional[float] = None) -> TaskAsyncResponse:
|
|
128
|
+
"""Submit a SAM task and return immediately. Use get() or stream() for results."""
|
|
129
|
+
data = self._http.request("POST", "/v1/sam/tasks/async",
|
|
130
|
+
json_body=_submit_body(task, model, priority, max_nodes, metadata),
|
|
131
|
+
timeout=request_timeout)
|
|
132
|
+
return TaskAsyncResponse(id=data["id"], model=data.get("model", model),
|
|
133
|
+
status="running", created_at=data.get("created_at", ""))
|
|
134
|
+
|
|
135
|
+
def get(self, task_id: str, *, request_timeout: Optional[float] = None) -> TaskResponse:
|
|
136
|
+
"""Poll a task for its current status and result."""
|
|
137
|
+
return _parse(self._http.request("GET", f"/v1/sam/tasks/{task_id}", timeout=request_timeout))
|
|
138
|
+
|
|
139
|
+
def stream(self, task_id: str, *, timeout: Optional[float] = None) -> SyncStreamContext:
|
|
140
|
+
"""Stream execution events for a task. Returns a context manager."""
|
|
141
|
+
return SyncStreamContext(self._http, task_id, timeout=timeout)
|
|
142
|
+
|
|
143
|
+
def list_models(self) -> List[Model]:
|
|
144
|
+
"""List all available SAM models."""
|
|
145
|
+
data = self._http.request("GET", "/v1/sam/models")
|
|
146
|
+
return [Model(id=m["id"], name=m["name"], description=m["description"],
|
|
147
|
+
max_nodes=m.get("max_nodes", 10)) for m in data.get("models", [])]
|
|
148
|
+
|
|
149
|
+
class AsyncTasks:
|
|
150
|
+
"""Async tasks resource. Access via async_client.tasks."""
|
|
151
|
+
def __init__(self, http: AsyncHttpClient) -> None:
|
|
152
|
+
self._http = http
|
|
153
|
+
|
|
154
|
+
async def create(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
|
|
155
|
+
max_nodes: Optional[int] = None, timeout: int = _DEFAULT_TIMEOUT,
|
|
156
|
+
metadata: Optional[Dict[str, Any]] = None, request_timeout: Optional[float] = None) -> TaskResponse:
|
|
157
|
+
"""Async version of Tasks.create()."""
|
|
158
|
+
data = await self._http.request("POST", "/v1/sam/tasks",
|
|
159
|
+
json_body=_create_body(task, model, priority, max_nodes, timeout, metadata),
|
|
160
|
+
timeout=request_timeout)
|
|
161
|
+
resp = _parse(data)
|
|
162
|
+
if resp.status == "running": raise TaskTimeoutError(resp.id, timeout)
|
|
163
|
+
if resp.status == "failed": raise TaskFailedError(resp.id, resp.error)
|
|
164
|
+
return resp
|
|
165
|
+
|
|
166
|
+
async def submit(self, task: str, *, model: str = _DEFAULT_MODEL, priority: str = _DEFAULT_PRIORITY,
|
|
167
|
+
max_nodes: Optional[int] = None, metadata: Optional[Dict[str, Any]] = None,
|
|
168
|
+
request_timeout: Optional[float] = None) -> TaskAsyncResponse:
|
|
169
|
+
"""Async version of Tasks.submit()."""
|
|
170
|
+
data = await self._http.request("POST", "/v1/sam/tasks/async",
|
|
171
|
+
json_body=_submit_body(task, model, priority, max_nodes, metadata),
|
|
172
|
+
timeout=request_timeout)
|
|
173
|
+
return TaskAsyncResponse(id=data["id"], model=data.get("model", model),
|
|
174
|
+
status="running", created_at=data.get("created_at", ""))
|
|
175
|
+
|
|
176
|
+
async def get(self, task_id: str, *, request_timeout: Optional[float] = None) -> TaskResponse:
|
|
177
|
+
"""Async version of Tasks.get()."""
|
|
178
|
+
return _parse(await self._http.request("GET", f"/v1/sam/tasks/{task_id}", timeout=request_timeout))
|
|
179
|
+
|
|
180
|
+
def stream(self, task_id: str, *, timeout: Optional[float] = None) -> AsyncStreamContext:
|
|
181
|
+
"""Async version of Tasks.stream()."""
|
|
182
|
+
return AsyncStreamContext(self._http, task_id, timeout=timeout)
|
|
183
|
+
|
|
184
|
+
async def list_models(self) -> List[Model]:
|
|
185
|
+
"""List all available SAM models."""
|
|
186
|
+
data = await self._http.request("GET", "/v1/sam/models")
|
|
187
|
+
return [Model(id=m["id"], name=m["name"], description=m["description"],
|
|
188
|
+
max_nodes=m.get("max_nodes", 10)) for m in data.get("models", [])]
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Dict, Literal, Optional
|
|
4
|
+
|
|
5
|
+
__all__ = ["TaskResponse", "TaskAsyncResponse", "StreamEvent", "Model"]
|
|
6
|
+
|
|
7
|
+
TaskStatus = Literal["completed", "failed", "running", "cancelled"]
|
|
8
|
+
StreamEventType = Literal[
|
|
9
|
+
"task.created", "task.planned", "node.started", "node.completed",
|
|
10
|
+
"node.failed", "task.completed", "task.failed", "task.update", "stream.end",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class TaskResponse:
|
|
15
|
+
"""Response from client.tasks.create() or client.tasks.get()."""
|
|
16
|
+
id: str
|
|
17
|
+
model: str
|
|
18
|
+
status: TaskStatus
|
|
19
|
+
output: Optional[str]
|
|
20
|
+
nodes_used: int
|
|
21
|
+
execution_ms: Optional[int]
|
|
22
|
+
created_at: str
|
|
23
|
+
metadata: Optional[Dict[str, Any]] = None
|
|
24
|
+
error: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
preview = (self.output or "")[:60]
|
|
28
|
+
if len(self.output or "") > 60:
|
|
29
|
+
preview += "..."
|
|
30
|
+
return f"TaskResponse(id={self.id!r}, status={self.status!r}, output={preview!r})"
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class TaskAsyncResponse:
|
|
34
|
+
"""Response from client.tasks.submit() — task accepted, not yet complete."""
|
|
35
|
+
id: str
|
|
36
|
+
model: str
|
|
37
|
+
status: TaskStatus
|
|
38
|
+
created_at: str
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class StreamEvent:
|
|
42
|
+
"""A single event from a streaming task execution."""
|
|
43
|
+
type: StreamEventType
|
|
44
|
+
task_id: str
|
|
45
|
+
summary: str = ""
|
|
46
|
+
timestamp: Optional[str] = None
|
|
47
|
+
node: Optional[str] = None
|
|
48
|
+
output: Optional[str] = None
|
|
49
|
+
nodes_used: Optional[int] = None
|
|
50
|
+
error: Optional[str] = None
|
|
51
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def __repr__(self) -> str:
|
|
54
|
+
if self.type == "task.completed":
|
|
55
|
+
return f"StreamEvent(type='task.completed', output={str(self.output or '')[:50]!r})"
|
|
56
|
+
if self.type in ("node.started", "node.completed", "node.failed"):
|
|
57
|
+
return f"StreamEvent(type={self.type!r}, node={self.node!r})"
|
|
58
|
+
return f"StreamEvent(type={self.type!r}, summary={self.summary[:40]!r})"
|
|
59
|
+
|
|
60
|
+
@dataclass(frozen=True)
|
|
61
|
+
class Model:
|
|
62
|
+
"""A SAM model available for task execution."""
|
|
63
|
+
id: str
|
|
64
|
+
name: str
|
|
65
|
+
description: str
|
|
66
|
+
max_nodes: int
|
|
67
|
+
|
|
68
|
+
def __repr__(self) -> str:
|
|
69
|
+
return f"Model(id={self.id!r}, name={self.name!r})"
|
|
File without changes
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json, pytest, respx, httpx
|
|
3
|
+
from typing import Any, Dict
|
|
4
|
+
import qubac
|
|
5
|
+
from qubac import (
|
|
6
|
+
Client, AsyncClient, TaskResponse, TaskAsyncResponse, StreamEvent,
|
|
7
|
+
AuthenticationError, RateLimitError, TaskTimeoutError, TaskFailedError, NotFoundError,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
BASE = "https://api.qubac.com"
|
|
11
|
+
KEY = "sk-test"
|
|
12
|
+
|
|
13
|
+
DONE: Dict[str, Any] = {
|
|
14
|
+
"id": "tsk_abc123def456789012345678901234",
|
|
15
|
+
"model": "sam-1", "status": "completed",
|
|
16
|
+
"output": "The speed of light is 299,792,458 m/s.",
|
|
17
|
+
"nodes_used": 1, "execution_ms": 3200,
|
|
18
|
+
"created_at": "2026-04-29T00:00:00Z", "metadata": None, "error": None,
|
|
19
|
+
}
|
|
20
|
+
FAIL: Dict[str, Any] = {**DONE, "status": "failed", "output": None, "error": "Task failed."}
|
|
21
|
+
RUN: Dict[str, Any] = {**DONE, "status": "running", "output": None, "execution_ms": None}
|
|
22
|
+
ASYNC_R: Dict[str, Any] = {"id": "tsk_asyncid123456789012345678", "model": "sam-1",
|
|
23
|
+
"status": "running", "created_at": "2026-04-29T00:00:00Z"}
|
|
24
|
+
MODELS: Dict[str, Any] = {"models": [
|
|
25
|
+
{"id": "sam-1", "name": "SAM 1", "description": "Default", "max_nodes": 10},
|
|
26
|
+
{"id": "sam-1-fast", "name": "SAM 1 Fast", "description": "Fast", "max_nodes": 5},
|
|
27
|
+
{"id": "sam-1-pro", "name": "SAM 1 Pro", "description": "Pro", "max_nodes": 20},
|
|
28
|
+
], "default": "sam-1"}
|
|
29
|
+
|
|
30
|
+
class TestClient:
|
|
31
|
+
def test_requires_api_key(self):
|
|
32
|
+
with pytest.raises(AuthenticationError): Client()
|
|
33
|
+
|
|
34
|
+
def test_env_variable(self, monkeypatch):
|
|
35
|
+
monkeypatch.setenv("QUBAC_API_KEY", "sk-env")
|
|
36
|
+
c = Client(); assert c._http._api_key == "sk-env"; c.close()
|
|
37
|
+
|
|
38
|
+
def test_repr(self):
|
|
39
|
+
c = Client(api_key=KEY, base_url=BASE)
|
|
40
|
+
assert "qubac.Client" in repr(c); c.close()
|
|
41
|
+
|
|
42
|
+
def test_context_manager(self):
|
|
43
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
44
|
+
assert c._http is not None
|
|
45
|
+
|
|
46
|
+
@respx.mock
|
|
47
|
+
def test_create_success(self):
|
|
48
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
|
|
49
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
50
|
+
r = c.tasks.create(model="sam-1", task="test")
|
|
51
|
+
assert isinstance(r, TaskResponse)
|
|
52
|
+
assert r.status == "completed"
|
|
53
|
+
assert r.output == "The speed of light is 299,792,458 m/s."
|
|
54
|
+
assert r.nodes_used == 1
|
|
55
|
+
|
|
56
|
+
@respx.mock
|
|
57
|
+
def test_create_raises_failed(self):
|
|
58
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=FAIL))
|
|
59
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
60
|
+
with pytest.raises(TaskFailedError) as e:
|
|
61
|
+
c.tasks.create(model="sam-1", task="test")
|
|
62
|
+
assert e.value.task_id == FAIL["id"]
|
|
63
|
+
|
|
64
|
+
@respx.mock
|
|
65
|
+
def test_create_raises_timeout(self):
|
|
66
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=RUN))
|
|
67
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
68
|
+
with pytest.raises(TaskTimeoutError) as e:
|
|
69
|
+
c.tasks.create(model="sam-1", task="test", timeout=30)
|
|
70
|
+
assert e.value.timeout == 30
|
|
71
|
+
|
|
72
|
+
@respx.mock
|
|
73
|
+
def test_submit(self):
|
|
74
|
+
respx.post(f"{BASE}/v1/sam/tasks/async").mock(return_value=httpx.Response(200, json=ASYNC_R))
|
|
75
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
76
|
+
r = c.tasks.submit(model="sam-1", task="test")
|
|
77
|
+
assert isinstance(r, TaskAsyncResponse)
|
|
78
|
+
assert r.status == "running"
|
|
79
|
+
|
|
80
|
+
@respx.mock
|
|
81
|
+
def test_get(self):
|
|
82
|
+
respx.get(f"{BASE}/v1/sam/tasks/{DONE['id']}").mock(return_value=httpx.Response(200, json=DONE))
|
|
83
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
84
|
+
r = c.tasks.get(DONE["id"])
|
|
85
|
+
assert r.status == "completed"
|
|
86
|
+
|
|
87
|
+
@respx.mock
|
|
88
|
+
def test_list_models(self):
|
|
89
|
+
respx.get(f"{BASE}/v1/sam/models").mock(return_value=httpx.Response(200, json=MODELS))
|
|
90
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
91
|
+
models = c.tasks.list_models()
|
|
92
|
+
assert len(models) == 3
|
|
93
|
+
assert models[0].id == "sam-1"
|
|
94
|
+
|
|
95
|
+
@respx.mock
|
|
96
|
+
def test_with_options(self):
|
|
97
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
|
|
98
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
99
|
+
r = c.with_options(timeout=60).tasks.create(model="sam-1", task="test")
|
|
100
|
+
assert r.status == "completed"
|
|
101
|
+
|
|
102
|
+
class TestErrors:
|
|
103
|
+
@respx.mock
|
|
104
|
+
def test_auth_error(self):
|
|
105
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(401, json={"detail": "Invalid key"}))
|
|
106
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
107
|
+
with pytest.raises(AuthenticationError) as e: c.tasks.create(model="sam-1", task="test")
|
|
108
|
+
assert e.value.status_code == 401
|
|
109
|
+
|
|
110
|
+
@respx.mock
|
|
111
|
+
def test_rate_limit(self):
|
|
112
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(
|
|
113
|
+
return_value=httpx.Response(429, json={"detail": "Exhausted", "reset_at": "2026-04-30T00:00:00Z"}))
|
|
114
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
115
|
+
with pytest.raises(RateLimitError) as e: c.tasks.create(model="sam-1", task="test")
|
|
116
|
+
assert e.value.reset_at == "2026-04-30T00:00:00Z"
|
|
117
|
+
|
|
118
|
+
@respx.mock
|
|
119
|
+
def test_retries_on_500(self):
|
|
120
|
+
calls = 0
|
|
121
|
+
def handler(req):
|
|
122
|
+
nonlocal calls; calls += 1
|
|
123
|
+
return httpx.Response(200, json=DONE) if calls >= 3 else httpx.Response(500, json={"detail": "err"})
|
|
124
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(side_effect=handler)
|
|
125
|
+
with Client(api_key=KEY, base_url=BASE, max_retries=2) as c:
|
|
126
|
+
r = c.tasks.create(model="sam-1", task="test")
|
|
127
|
+
assert r.status == "completed"
|
|
128
|
+
assert calls == 3
|
|
129
|
+
|
|
130
|
+
@respx.mock
|
|
131
|
+
def test_metadata_passthrough(self):
|
|
132
|
+
meta = {"user_id": "u1", "source": "test"}
|
|
133
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json={**DONE, "metadata": meta}))
|
|
134
|
+
with Client(api_key=KEY, base_url=BASE) as c:
|
|
135
|
+
r = c.tasks.create(model="sam-1", task="test", metadata=meta)
|
|
136
|
+
assert r.metadata == meta
|
|
137
|
+
|
|
138
|
+
class TestAsync:
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
@respx.mock
|
|
141
|
+
async def test_create(self):
|
|
142
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
|
|
143
|
+
async with AsyncClient(api_key=KEY, base_url=BASE) as c:
|
|
144
|
+
r = await c.tasks.create(model="sam-1", task="test")
|
|
145
|
+
assert r.status == "completed"
|
|
146
|
+
|
|
147
|
+
@pytest.mark.asyncio
|
|
148
|
+
@respx.mock
|
|
149
|
+
async def test_parallel(self):
|
|
150
|
+
import asyncio
|
|
151
|
+
respx.post(f"{BASE}/v1/sam/tasks").mock(return_value=httpx.Response(200, json=DONE))
|
|
152
|
+
async with AsyncClient(api_key=KEY, base_url=BASE) as c:
|
|
153
|
+
results = await asyncio.gather(
|
|
154
|
+
c.tasks.create(model="sam-1", task="t1"),
|
|
155
|
+
c.tasks.create(model="sam-1", task="t2"),
|
|
156
|
+
c.tasks.create(model="sam-1", task="t3"),
|
|
157
|
+
)
|
|
158
|
+
assert all(r.status == "completed" for r in results)
|
|
159
|
+
|
|
160
|
+
class TestStreaming:
|
|
161
|
+
@respx.mock
|
|
162
|
+
def test_stream(self):
|
|
163
|
+
task_id = "tsk_streamtest1234567890123456"
|
|
164
|
+
evts = [
|
|
165
|
+
{"type": "task.created", "task_id": task_id, "summary": "Received"},
|
|
166
|
+
{"type": "task.planned", "task_id": task_id, "summary": "Planned"},
|
|
167
|
+
{"type": "node.started", "task_id": task_id, "summary": "", "node": "Direct response"},
|
|
168
|
+
{"type": "node.completed", "task_id": task_id, "summary": "", "node": "Direct response"},
|
|
169
|
+
{"type": "task.completed", "task_id": task_id, "summary": "", "output": "42", "nodes_used": 1},
|
|
170
|
+
{"type": "stream.end", "task_id": task_id, "summary": ""},
|
|
171
|
+
]
|
|
172
|
+
body = "\n".join(f"data: {json.dumps(e)}\n" for e in evts)
|
|
173
|
+
respx.get(f"{BASE}/v1/sam/tasks/{task_id}/stream").mock(
|
|
174
|
+
return_value=httpx.Response(200, text=body, headers={"content-type": "text/event-stream"}))
|
|
175
|
+
with qubac.Client(api_key=KEY, base_url=BASE) as c:
|
|
176
|
+
received = []
|
|
177
|
+
with c.tasks.stream(task_id) as s:
|
|
178
|
+
for e in s: received.append(e)
|
|
179
|
+
assert received[-1].type == "task.completed"
|
|
180
|
+
assert received[-1].output == "42"
|
|
181
|
+
assert s.final_response is not None
|
|
182
|
+
assert s.final_response.status == "completed"
|
|
183
|
+
|
|
184
|
+
class TestTypes:
|
|
185
|
+
def test_frozen(self):
|
|
186
|
+
r = TaskResponse(id="tsk_x", model="sam-1", status="completed",
|
|
187
|
+
output="hi", nodes_used=1, execution_ms=100, created_at="")
|
|
188
|
+
with pytest.raises(Exception): r.output = "changed" # type: ignore
|
|
189
|
+
|
|
190
|
+
def test_repr(self):
|
|
191
|
+
r = TaskResponse(id="tsk_x", model="sam-1", status="completed",
|
|
192
|
+
output="hello world", nodes_used=1, execution_ms=100, created_at="")
|
|
193
|
+
assert "TaskResponse" in repr(r) and "completed" in repr(r)
|
|
194
|
+
|
|
195
|
+
def test_version(self):
|
|
196
|
+
assert qubac.__version__ == "1.0.0"
|