caether 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.
- caether-0.1.0/LICENSE +21 -0
- caether-0.1.0/PKG-INFO +60 -0
- caether-0.1.0/README.md +30 -0
- caether-0.1.0/caether/__init__.py +35 -0
- caether-0.1.0/caether/_client.py +88 -0
- caether-0.1.0/caether/_constants.py +10 -0
- caether-0.1.0/caether/_errors.py +100 -0
- caether-0.1.0/caether/_transport.py +246 -0
- caether-0.1.0/caether/aio/__init__.py +3 -0
- caether-0.1.0/caether/chat/__init__.py +23 -0
- caether-0.1.0/caether/chat/_async_chat.py +156 -0
- caether-0.1.0/caether/chat/_chat.py +154 -0
- caether-0.1.0/caether/chat/_content.py +47 -0
- caether-0.1.0/caether/chat/_types.py +127 -0
- caether-0.1.0/pyproject.toml +40 -0
caether-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CaetherAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
caether-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: caether
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the CaetherAI API
|
|
5
|
+
Project-URL: Homepage, https://caether.ai
|
|
6
|
+
Project-URL: Documentation, https://docs.caether.ai
|
|
7
|
+
Author: CaetherAI
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: ai,api,caether,caether-ai,caetherai,llm,sdk
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
23
|
+
Requires-Python: >=3.8
|
|
24
|
+
Requires-Dist: httpx>=0.24.0
|
|
25
|
+
Provides-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
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# CaetherAI Python SDK
|
|
32
|
+
|
|
33
|
+
Official Python SDK for the [CaetherAI](https://caether.ai) API. Synchronous and asynchronous
|
|
34
|
+
clients with a unified interface across products.
|
|
35
|
+
|
|
36
|
+
## Installation
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install caether
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quickstart
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import os
|
|
46
|
+
from caether import Client
|
|
47
|
+
from caether.chat import user, system
|
|
48
|
+
|
|
49
|
+
client = Client(api_key=os.getenv("CAETHER_API_KEY"))
|
|
50
|
+
|
|
51
|
+
chat = client.chat.create(model="caether-1.1")
|
|
52
|
+
chat.append(system("You are Caether, an AI agent built to answer helpful questions."))
|
|
53
|
+
chat.append(user("How big is the universe?"))
|
|
54
|
+
response = chat.sample()
|
|
55
|
+
|
|
56
|
+
print(response)
|
|
57
|
+
print(response.id)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
See [docs.caether.ai](https://docs.caether.ai) for full documentation.
|
caether-0.1.0/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# CaetherAI Python SDK
|
|
2
|
+
|
|
3
|
+
Official Python SDK for the [CaetherAI](https://caether.ai) API. Synchronous and asynchronous
|
|
4
|
+
clients with a unified interface across products.
|
|
5
|
+
|
|
6
|
+
## Installation
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install caether
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
import os
|
|
16
|
+
from caether import Client
|
|
17
|
+
from caether.chat import user, system
|
|
18
|
+
|
|
19
|
+
client = Client(api_key=os.getenv("CAETHER_API_KEY"))
|
|
20
|
+
|
|
21
|
+
chat = client.chat.create(model="caether-1.1")
|
|
22
|
+
chat.append(system("You are Caether, an AI agent built to answer helpful questions."))
|
|
23
|
+
chat.append(user("How big is the universe?"))
|
|
24
|
+
response = chat.sample()
|
|
25
|
+
|
|
26
|
+
print(response)
|
|
27
|
+
print(response.id)
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
See [docs.caether.ai](https://docs.caether.ai) for full documentation.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
from ._client import AsyncClient, Client
|
|
2
|
+
from ._constants import DEFAULT_BASE_URL, SDK_VERSION
|
|
3
|
+
from ._errors import (
|
|
4
|
+
APIConnectionError,
|
|
5
|
+
APIStatusError,
|
|
6
|
+
APITimeoutError,
|
|
7
|
+
AuthenticationError,
|
|
8
|
+
BadRequestError,
|
|
9
|
+
CaetherError,
|
|
10
|
+
InternalServerError,
|
|
11
|
+
NotFoundError,
|
|
12
|
+
PermissionDeniedError,
|
|
13
|
+
RateLimitError,
|
|
14
|
+
UnprocessableEntityError,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__version__ = SDK_VERSION
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"Client",
|
|
21
|
+
"AsyncClient",
|
|
22
|
+
"DEFAULT_BASE_URL",
|
|
23
|
+
"CaetherError",
|
|
24
|
+
"APIConnectionError",
|
|
25
|
+
"APITimeoutError",
|
|
26
|
+
"APIStatusError",
|
|
27
|
+
"AuthenticationError",
|
|
28
|
+
"PermissionDeniedError",
|
|
29
|
+
"NotFoundError",
|
|
30
|
+
"RateLimitError",
|
|
31
|
+
"BadRequestError",
|
|
32
|
+
"UnprocessableEntityError",
|
|
33
|
+
"InternalServerError",
|
|
34
|
+
"__version__",
|
|
35
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from typing import Mapping, Optional
|
|
5
|
+
|
|
6
|
+
import httpx
|
|
7
|
+
|
|
8
|
+
from ._constants import DEFAULT_BASE_URL, DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT
|
|
9
|
+
from ._errors import CaetherError
|
|
10
|
+
from ._transport import AsyncTransport, SyncTransport
|
|
11
|
+
from .chat import AsyncChatClient, ChatClient
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _resolve_api_key(api_key: Optional[str]) -> str:
|
|
15
|
+
key = api_key or os.getenv("CAETHER_API_KEY")
|
|
16
|
+
if not key:
|
|
17
|
+
raise CaetherError(
|
|
18
|
+
"No API key provided. Pass api_key=... or set the CAETHER_API_KEY environment variable."
|
|
19
|
+
)
|
|
20
|
+
return key
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Client:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
api_key: Optional[str] = None,
|
|
27
|
+
*,
|
|
28
|
+
base_url: Optional[str] = None,
|
|
29
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
30
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
31
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
32
|
+
http_client: Optional[httpx.Client] = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
self.api_key = _resolve_api_key(api_key)
|
|
35
|
+
self.base_url = (base_url or os.getenv("CAETHER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
36
|
+
self._transport = SyncTransport(
|
|
37
|
+
base_url=self.base_url,
|
|
38
|
+
api_key=self.api_key,
|
|
39
|
+
timeout=timeout,
|
|
40
|
+
max_retries=max_retries,
|
|
41
|
+
default_headers=default_headers,
|
|
42
|
+
http_client=http_client,
|
|
43
|
+
)
|
|
44
|
+
self.chat = ChatClient(self._transport, path="/chat/completions")
|
|
45
|
+
self.code = ChatClient(self._transport, path="/code/stream")
|
|
46
|
+
|
|
47
|
+
def close(self) -> None:
|
|
48
|
+
self._transport.close()
|
|
49
|
+
|
|
50
|
+
def __enter__(self) -> "Client":
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def __exit__(self, *exc: object) -> None:
|
|
54
|
+
self.close()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AsyncClient:
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
api_key: Optional[str] = None,
|
|
61
|
+
*,
|
|
62
|
+
base_url: Optional[str] = None,
|
|
63
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
64
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
65
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
66
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
self.api_key = _resolve_api_key(api_key)
|
|
69
|
+
self.base_url = (base_url or os.getenv("CAETHER_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
70
|
+
self._transport = AsyncTransport(
|
|
71
|
+
base_url=self.base_url,
|
|
72
|
+
api_key=self.api_key,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
max_retries=max_retries,
|
|
75
|
+
default_headers=default_headers,
|
|
76
|
+
http_client=http_client,
|
|
77
|
+
)
|
|
78
|
+
self.chat = AsyncChatClient(self._transport, path="/chat/completions")
|
|
79
|
+
self.code = AsyncChatClient(self._transport, path="/code/stream")
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
await self._transport.close()
|
|
83
|
+
|
|
84
|
+
async def __aenter__(self) -> "AsyncClient":
|
|
85
|
+
return self
|
|
86
|
+
|
|
87
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
88
|
+
await self.close()
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Mapping, Optional
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class CaetherError(Exception):
|
|
7
|
+
def __init__(
|
|
8
|
+
self,
|
|
9
|
+
message: str,
|
|
10
|
+
*,
|
|
11
|
+
status_code: Optional[int] = None,
|
|
12
|
+
code: Optional[str] = None,
|
|
13
|
+
body: Optional[Any] = None,
|
|
14
|
+
) -> None:
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.message = message
|
|
17
|
+
self.status_code = status_code
|
|
18
|
+
self.code = code
|
|
19
|
+
self.body = body
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str:
|
|
22
|
+
parts = [self.message]
|
|
23
|
+
if self.code:
|
|
24
|
+
parts.append(f"code={self.code}")
|
|
25
|
+
if self.status_code is not None:
|
|
26
|
+
parts.append(f"status={self.status_code}")
|
|
27
|
+
return " ".join(parts) if len(parts) == 1 else f"{self.message} ({', '.join(parts[1:])})"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class APIConnectionError(CaetherError):
|
|
31
|
+
pass
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class APITimeoutError(APIConnectionError):
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class APIStatusError(CaetherError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class AuthenticationError(APIStatusError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class PermissionDeniedError(APIStatusError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class NotFoundError(APIStatusError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RateLimitError(APIStatusError):
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BadRequestError(APIStatusError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class UnprocessableEntityError(APIStatusError):
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class InternalServerError(APIStatusError):
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
_STATUS_TO_ERROR = {
|
|
71
|
+
400: BadRequestError,
|
|
72
|
+
401: AuthenticationError,
|
|
73
|
+
403: PermissionDeniedError,
|
|
74
|
+
404: NotFoundError,
|
|
75
|
+
422: UnprocessableEntityError,
|
|
76
|
+
429: RateLimitError,
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def error_from_response(status_code: int, body: Any) -> APIStatusError:
|
|
81
|
+
message = f"HTTP {status_code}"
|
|
82
|
+
code: Optional[str] = None
|
|
83
|
+
if isinstance(body, Mapping):
|
|
84
|
+
err = body.get("error")
|
|
85
|
+
if isinstance(err, Mapping):
|
|
86
|
+
message = err.get("message") or message
|
|
87
|
+
code = err.get("code")
|
|
88
|
+
elif isinstance(err, str):
|
|
89
|
+
message = err
|
|
90
|
+
elif isinstance(body.get("message"), str):
|
|
91
|
+
message = body["message"]
|
|
92
|
+
|
|
93
|
+
if status_code in _STATUS_TO_ERROR:
|
|
94
|
+
cls = _STATUS_TO_ERROR[status_code]
|
|
95
|
+
elif status_code >= 500:
|
|
96
|
+
cls = InternalServerError
|
|
97
|
+
else:
|
|
98
|
+
cls = APIStatusError
|
|
99
|
+
|
|
100
|
+
return cls(message, status_code=status_code, code=code, body=body)
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any, AsyncIterator, Dict, Iterator, Mapping, Optional
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from ._constants import DEFAULT_MAX_RETRIES, DEFAULT_TIMEOUT, SDK_VERSION, USER_AGENT
|
|
10
|
+
from ._errors import (
|
|
11
|
+
APIConnectionError,
|
|
12
|
+
APITimeoutError,
|
|
13
|
+
error_from_response,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
_RETRY_STATUS = {408, 409, 429, 500, 502, 503, 504}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_headers(api_key: str, extra: Optional[Mapping[str, str]]) -> Dict[str, str]:
|
|
20
|
+
headers = {
|
|
21
|
+
"Authorization": f"Bearer {api_key}",
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"Accept": "application/json",
|
|
24
|
+
"User-Agent": f"{USER_AGENT}/{SDK_VERSION}",
|
|
25
|
+
}
|
|
26
|
+
if extra:
|
|
27
|
+
headers.update({k: v for k, v in extra.items() if v is not None})
|
|
28
|
+
return headers
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _retry_delay(attempt: int) -> float:
|
|
32
|
+
return min(0.5 * (2 ** attempt), 8.0)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _parse_body(response: httpx.Response) -> Any:
|
|
36
|
+
ct = response.headers.get("content-type", "")
|
|
37
|
+
if "application/json" in ct:
|
|
38
|
+
try:
|
|
39
|
+
return response.json()
|
|
40
|
+
except ValueError:
|
|
41
|
+
return response.text
|
|
42
|
+
return response.text
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _iter_sse(lines: Iterator[str]) -> Iterator[Dict[str, Any]]:
|
|
46
|
+
for raw in lines:
|
|
47
|
+
line = raw.strip()
|
|
48
|
+
if not line or not line.startswith("data:"):
|
|
49
|
+
continue
|
|
50
|
+
data = line[len("data:"):].strip()
|
|
51
|
+
if data == "[DONE]":
|
|
52
|
+
return
|
|
53
|
+
try:
|
|
54
|
+
yield json.loads(data)
|
|
55
|
+
except ValueError:
|
|
56
|
+
continue
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SyncTransport:
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
*,
|
|
63
|
+
base_url: str,
|
|
64
|
+
api_key: str,
|
|
65
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
66
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
67
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
68
|
+
http_client: Optional[httpx.Client] = None,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._base_url = base_url.rstrip("/")
|
|
71
|
+
self._api_key = api_key
|
|
72
|
+
self._max_retries = max_retries
|
|
73
|
+
self._default_headers = dict(default_headers or {})
|
|
74
|
+
self._client = http_client or httpx.Client(timeout=timeout)
|
|
75
|
+
self._owns_client = http_client is None
|
|
76
|
+
|
|
77
|
+
def _url(self, path: str) -> str:
|
|
78
|
+
return f"{self._base_url}/{path.lstrip('/')}"
|
|
79
|
+
|
|
80
|
+
def request(
|
|
81
|
+
self,
|
|
82
|
+
method: str,
|
|
83
|
+
path: str,
|
|
84
|
+
*,
|
|
85
|
+
json_body: Optional[Any] = None,
|
|
86
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
87
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
88
|
+
files: Optional[Any] = None,
|
|
89
|
+
data: Optional[Mapping[str, Any]] = None,
|
|
90
|
+
) -> Any:
|
|
91
|
+
merged = _build_headers(self._api_key, {**self._default_headers, **(headers or {})})
|
|
92
|
+
if files is not None:
|
|
93
|
+
merged.pop("Content-Type", None)
|
|
94
|
+
last_exc: Optional[Exception] = None
|
|
95
|
+
for attempt in range(self._max_retries + 1):
|
|
96
|
+
try:
|
|
97
|
+
response = self._client.request(
|
|
98
|
+
method,
|
|
99
|
+
self._url(path),
|
|
100
|
+
json=json_body if files is None else None,
|
|
101
|
+
params=params,
|
|
102
|
+
headers=merged,
|
|
103
|
+
files=files,
|
|
104
|
+
data=data,
|
|
105
|
+
)
|
|
106
|
+
except httpx.TimeoutException as exc:
|
|
107
|
+
last_exc = APITimeoutError("Request timed out")
|
|
108
|
+
if attempt < self._max_retries:
|
|
109
|
+
time.sleep(_retry_delay(attempt))
|
|
110
|
+
continue
|
|
111
|
+
raise last_exc from exc
|
|
112
|
+
except httpx.HTTPError as exc:
|
|
113
|
+
last_exc = APIConnectionError(f"Connection error: {exc}")
|
|
114
|
+
if attempt < self._max_retries:
|
|
115
|
+
time.sleep(_retry_delay(attempt))
|
|
116
|
+
continue
|
|
117
|
+
raise last_exc from exc
|
|
118
|
+
|
|
119
|
+
if response.status_code in _RETRY_STATUS and attempt < self._max_retries:
|
|
120
|
+
time.sleep(_retry_delay(attempt))
|
|
121
|
+
continue
|
|
122
|
+
if response.status_code >= 400:
|
|
123
|
+
raise error_from_response(response.status_code, _parse_body(response))
|
|
124
|
+
return _parse_body(response)
|
|
125
|
+
raise last_exc or APIConnectionError("Request failed")
|
|
126
|
+
|
|
127
|
+
def stream(
|
|
128
|
+
self,
|
|
129
|
+
method: str,
|
|
130
|
+
path: str,
|
|
131
|
+
*,
|
|
132
|
+
json_body: Optional[Any] = None,
|
|
133
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
134
|
+
) -> Iterator[Dict[str, Any]]:
|
|
135
|
+
merged = _build_headers(self._api_key, {**self._default_headers, **(headers or {})})
|
|
136
|
+
merged["Accept"] = "text/event-stream"
|
|
137
|
+
with self._client.stream(method, self._url(path), json=json_body, headers=merged) as response:
|
|
138
|
+
if response.status_code >= 400:
|
|
139
|
+
response.read()
|
|
140
|
+
raise error_from_response(response.status_code, _parse_body(response))
|
|
141
|
+
yield from _iter_sse(response.iter_lines())
|
|
142
|
+
|
|
143
|
+
def close(self) -> None:
|
|
144
|
+
if self._owns_client:
|
|
145
|
+
self._client.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class AsyncTransport:
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
*,
|
|
152
|
+
base_url: str,
|
|
153
|
+
api_key: str,
|
|
154
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
155
|
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
|
156
|
+
default_headers: Optional[Mapping[str, str]] = None,
|
|
157
|
+
http_client: Optional[httpx.AsyncClient] = None,
|
|
158
|
+
) -> None:
|
|
159
|
+
self._base_url = base_url.rstrip("/")
|
|
160
|
+
self._api_key = api_key
|
|
161
|
+
self._max_retries = max_retries
|
|
162
|
+
self._default_headers = dict(default_headers or {})
|
|
163
|
+
self._client = http_client or httpx.AsyncClient(timeout=timeout)
|
|
164
|
+
self._owns_client = http_client is None
|
|
165
|
+
|
|
166
|
+
def _url(self, path: str) -> str:
|
|
167
|
+
return f"{self._base_url}/{path.lstrip('/')}"
|
|
168
|
+
|
|
169
|
+
async def request(
|
|
170
|
+
self,
|
|
171
|
+
method: str,
|
|
172
|
+
path: str,
|
|
173
|
+
*,
|
|
174
|
+
json_body: Optional[Any] = None,
|
|
175
|
+
params: Optional[Mapping[str, Any]] = None,
|
|
176
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
177
|
+
files: Optional[Any] = None,
|
|
178
|
+
data: Optional[Mapping[str, Any]] = None,
|
|
179
|
+
) -> Any:
|
|
180
|
+
import asyncio
|
|
181
|
+
|
|
182
|
+
merged = _build_headers(self._api_key, {**self._default_headers, **(headers or {})})
|
|
183
|
+
if files is not None:
|
|
184
|
+
merged.pop("Content-Type", None)
|
|
185
|
+
last_exc: Optional[Exception] = None
|
|
186
|
+
for attempt in range(self._max_retries + 1):
|
|
187
|
+
try:
|
|
188
|
+
response = await self._client.request(
|
|
189
|
+
method,
|
|
190
|
+
self._url(path),
|
|
191
|
+
json=json_body if files is None else None,
|
|
192
|
+
params=params,
|
|
193
|
+
headers=merged,
|
|
194
|
+
files=files,
|
|
195
|
+
data=data,
|
|
196
|
+
)
|
|
197
|
+
except httpx.TimeoutException as exc:
|
|
198
|
+
last_exc = APITimeoutError("Request timed out")
|
|
199
|
+
if attempt < self._max_retries:
|
|
200
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
201
|
+
continue
|
|
202
|
+
raise last_exc from exc
|
|
203
|
+
except httpx.HTTPError as exc:
|
|
204
|
+
last_exc = APIConnectionError(f"Connection error: {exc}")
|
|
205
|
+
if attempt < self._max_retries:
|
|
206
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
207
|
+
continue
|
|
208
|
+
raise last_exc from exc
|
|
209
|
+
|
|
210
|
+
if response.status_code in _RETRY_STATUS and attempt < self._max_retries:
|
|
211
|
+
await asyncio.sleep(_retry_delay(attempt))
|
|
212
|
+
continue
|
|
213
|
+
if response.status_code >= 400:
|
|
214
|
+
raise error_from_response(response.status_code, _parse_body(response))
|
|
215
|
+
return _parse_body(response)
|
|
216
|
+
raise last_exc or APIConnectionError("Request failed")
|
|
217
|
+
|
|
218
|
+
async def stream(
|
|
219
|
+
self,
|
|
220
|
+
method: str,
|
|
221
|
+
path: str,
|
|
222
|
+
*,
|
|
223
|
+
json_body: Optional[Any] = None,
|
|
224
|
+
headers: Optional[Mapping[str, str]] = None,
|
|
225
|
+
) -> AsyncIterator[Dict[str, Any]]:
|
|
226
|
+
merged = _build_headers(self._api_key, {**self._default_headers, **(headers or {})})
|
|
227
|
+
merged["Accept"] = "text/event-stream"
|
|
228
|
+
async with self._client.stream(method, self._url(path), json=json_body, headers=merged) as response:
|
|
229
|
+
if response.status_code >= 400:
|
|
230
|
+
await response.aread()
|
|
231
|
+
raise error_from_response(response.status_code, _parse_body(response))
|
|
232
|
+
async for raw in response.aiter_lines():
|
|
233
|
+
line = raw.strip()
|
|
234
|
+
if not line or not line.startswith("data:"):
|
|
235
|
+
continue
|
|
236
|
+
payload = line[len("data:"):].strip()
|
|
237
|
+
if payload == "[DONE]":
|
|
238
|
+
return
|
|
239
|
+
try:
|
|
240
|
+
yield json.loads(payload)
|
|
241
|
+
except ValueError:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
async def close(self) -> None:
|
|
245
|
+
if self._owns_client:
|
|
246
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from ._async_chat import AsyncChat, AsyncChatClient
|
|
2
|
+
from ._chat import Chat, ChatClient
|
|
3
|
+
from ._content import assistant, file, image, system, text, tool_result, user, video
|
|
4
|
+
from ._types import FunctionCall, Response, ResponseChunk, Usage
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Chat",
|
|
8
|
+
"ChatClient",
|
|
9
|
+
"AsyncChat",
|
|
10
|
+
"AsyncChatClient",
|
|
11
|
+
"Response",
|
|
12
|
+
"ResponseChunk",
|
|
13
|
+
"Usage",
|
|
14
|
+
"FunctionCall",
|
|
15
|
+
"system",
|
|
16
|
+
"user",
|
|
17
|
+
"assistant",
|
|
18
|
+
"text",
|
|
19
|
+
"image",
|
|
20
|
+
"video",
|
|
21
|
+
"file",
|
|
22
|
+
"tool_result",
|
|
23
|
+
]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, AsyncIterator, Dict, List, Mapping, Optional, Tuple, Union
|
|
4
|
+
|
|
5
|
+
from .._transport import AsyncTransport
|
|
6
|
+
from ._types import Response, ResponseChunk
|
|
7
|
+
|
|
8
|
+
Message = Dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AsyncChat:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
transport: AsyncTransport,
|
|
15
|
+
*,
|
|
16
|
+
model: str,
|
|
17
|
+
path: str,
|
|
18
|
+
store_messages: bool = True,
|
|
19
|
+
previous_response_id: Optional[str] = None,
|
|
20
|
+
use_encrypted_content: bool = False,
|
|
21
|
+
temperature: Optional[float] = None,
|
|
22
|
+
top_p: Optional[float] = None,
|
|
23
|
+
max_output_tokens: Optional[int] = None,
|
|
24
|
+
reasoning_effort: Optional[str] = None,
|
|
25
|
+
tools: Optional[List[Any]] = None,
|
|
26
|
+
tool_choice: Optional[Any] = None,
|
|
27
|
+
extra_body: Optional[Mapping[str, Any]] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._transport = transport
|
|
30
|
+
self._path = path
|
|
31
|
+
self.model = model
|
|
32
|
+
self.store_messages = store_messages
|
|
33
|
+
self.previous_response_id = previous_response_id
|
|
34
|
+
self.use_encrypted_content = use_encrypted_content
|
|
35
|
+
self.temperature = temperature
|
|
36
|
+
self.top_p = top_p
|
|
37
|
+
self.max_output_tokens = max_output_tokens
|
|
38
|
+
self.reasoning_effort = reasoning_effort
|
|
39
|
+
self.tools = tools
|
|
40
|
+
self.tool_choice = tool_choice
|
|
41
|
+
self.extra_body = dict(extra_body or {})
|
|
42
|
+
self.messages: List[Message] = []
|
|
43
|
+
|
|
44
|
+
def append(self, message: Union[Message, Response]) -> "AsyncChat":
|
|
45
|
+
if isinstance(message, Response):
|
|
46
|
+
self.previous_response_id = message.id or self.previous_response_id
|
|
47
|
+
for item in message.output:
|
|
48
|
+
self.messages.append(item)
|
|
49
|
+
else:
|
|
50
|
+
self.messages.append(message)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def _build_body(self, stream: bool) -> Dict[str, Any]:
|
|
54
|
+
body: Dict[str, Any] = {
|
|
55
|
+
"model": self.model,
|
|
56
|
+
"messages": self.messages,
|
|
57
|
+
"stream": stream,
|
|
58
|
+
"store": self.store_messages,
|
|
59
|
+
}
|
|
60
|
+
if self.previous_response_id is not None:
|
|
61
|
+
body["previous_response_id"] = self.previous_response_id
|
|
62
|
+
if self.temperature is not None:
|
|
63
|
+
body["temperature"] = self.temperature
|
|
64
|
+
if self.top_p is not None:
|
|
65
|
+
body["top_p"] = self.top_p
|
|
66
|
+
if self.max_output_tokens is not None:
|
|
67
|
+
body["max_output_tokens"] = self.max_output_tokens
|
|
68
|
+
if self.reasoning_effort is not None:
|
|
69
|
+
body["reasoning_effort"] = self.reasoning_effort
|
|
70
|
+
if self.tools is not None:
|
|
71
|
+
body["tools"] = self.tools
|
|
72
|
+
if self.tool_choice is not None:
|
|
73
|
+
body["tool_choice"] = self.tool_choice
|
|
74
|
+
if self.use_encrypted_content:
|
|
75
|
+
body["include"] = ["reasoning.encrypted_content"]
|
|
76
|
+
body.update(self.extra_body)
|
|
77
|
+
return body
|
|
78
|
+
|
|
79
|
+
async def sample(self) -> Response:
|
|
80
|
+
data = await self._transport.request("POST", self._path, json_body=self._build_body(stream=False))
|
|
81
|
+
response = Response.from_dict(data)
|
|
82
|
+
if response.id:
|
|
83
|
+
self.previous_response_id = response.id
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
def stream(self) -> Tuple[Response, AsyncIterator[ResponseChunk]]:
|
|
87
|
+
accumulator = Response(
|
|
88
|
+
id=None, model=self.model, content="", reasoning_content=None,
|
|
89
|
+
output=[], function_calls=[], usage=None, raw={}, # type: ignore[arg-type]
|
|
90
|
+
)
|
|
91
|
+
body = self._build_body(stream=True)
|
|
92
|
+
transport = self._transport
|
|
93
|
+
path = self._path
|
|
94
|
+
|
|
95
|
+
async def _generator() -> AsyncIterator[ResponseChunk]:
|
|
96
|
+
content_parts: List[str] = []
|
|
97
|
+
reasoning_parts: List[str] = []
|
|
98
|
+
async for event in transport.stream("POST", path, json_body=body):
|
|
99
|
+
chunk = ResponseChunk.from_event(event)
|
|
100
|
+
if chunk.content:
|
|
101
|
+
content_parts.append(chunk.content)
|
|
102
|
+
accumulator.content = "".join(content_parts)
|
|
103
|
+
if chunk.reasoning_content:
|
|
104
|
+
reasoning_parts.append(chunk.reasoning_content)
|
|
105
|
+
accumulator.reasoning_content = "".join(reasoning_parts)
|
|
106
|
+
if chunk.usage is not None:
|
|
107
|
+
accumulator.usage = chunk.usage
|
|
108
|
+
if chunk.function_call is not None:
|
|
109
|
+
accumulator.function_calls.append(chunk.function_call)
|
|
110
|
+
yield chunk
|
|
111
|
+
|
|
112
|
+
return accumulator, _generator()
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AsyncChatClient:
|
|
116
|
+
def __init__(self, transport: AsyncTransport, *, path: str = "/chat/completions") -> None:
|
|
117
|
+
self._transport = transport
|
|
118
|
+
self._path = path
|
|
119
|
+
|
|
120
|
+
def create(
|
|
121
|
+
self,
|
|
122
|
+
*,
|
|
123
|
+
model: str,
|
|
124
|
+
store_messages: bool = True,
|
|
125
|
+
previous_response_id: Optional[str] = None,
|
|
126
|
+
use_encrypted_content: bool = False,
|
|
127
|
+
temperature: Optional[float] = None,
|
|
128
|
+
top_p: Optional[float] = None,
|
|
129
|
+
max_output_tokens: Optional[int] = None,
|
|
130
|
+
reasoning_effort: Optional[str] = None,
|
|
131
|
+
tools: Optional[List[Any]] = None,
|
|
132
|
+
tool_choice: Optional[Any] = None,
|
|
133
|
+
**extra_body: Any,
|
|
134
|
+
) -> AsyncChat:
|
|
135
|
+
return AsyncChat(
|
|
136
|
+
self._transport,
|
|
137
|
+
model=model,
|
|
138
|
+
path=self._path,
|
|
139
|
+
store_messages=store_messages,
|
|
140
|
+
previous_response_id=previous_response_id,
|
|
141
|
+
use_encrypted_content=use_encrypted_content,
|
|
142
|
+
temperature=temperature,
|
|
143
|
+
top_p=top_p,
|
|
144
|
+
max_output_tokens=max_output_tokens,
|
|
145
|
+
reasoning_effort=reasoning_effort,
|
|
146
|
+
tools=tools,
|
|
147
|
+
tool_choice=tool_choice,
|
|
148
|
+
extra_body=extra_body or None,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
async def get_stored_completion(self, response_id: str) -> Response:
|
|
152
|
+
data = await self._transport.request("GET", f"{self._path}/{response_id}")
|
|
153
|
+
return Response.from_dict(data)
|
|
154
|
+
|
|
155
|
+
async def delete_stored_completion(self, response_id: str) -> Dict[str, Any]:
|
|
156
|
+
return await self._transport.request("DELETE", f"{self._path}/{response_id}")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Iterator, List, Mapping, Optional, Tuple, Union
|
|
4
|
+
|
|
5
|
+
from .._transport import SyncTransport
|
|
6
|
+
from ._types import Response, ResponseChunk, _extract_output_text
|
|
7
|
+
|
|
8
|
+
Message = Dict[str, Any]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Chat:
|
|
12
|
+
def __init__(
|
|
13
|
+
self,
|
|
14
|
+
transport: SyncTransport,
|
|
15
|
+
*,
|
|
16
|
+
model: str,
|
|
17
|
+
path: str,
|
|
18
|
+
store_messages: bool = True,
|
|
19
|
+
previous_response_id: Optional[str] = None,
|
|
20
|
+
use_encrypted_content: bool = False,
|
|
21
|
+
temperature: Optional[float] = None,
|
|
22
|
+
top_p: Optional[float] = None,
|
|
23
|
+
max_output_tokens: Optional[int] = None,
|
|
24
|
+
reasoning_effort: Optional[str] = None,
|
|
25
|
+
tools: Optional[List[Any]] = None,
|
|
26
|
+
tool_choice: Optional[Any] = None,
|
|
27
|
+
extra_body: Optional[Mapping[str, Any]] = None,
|
|
28
|
+
) -> None:
|
|
29
|
+
self._transport = transport
|
|
30
|
+
self._path = path
|
|
31
|
+
self.model = model
|
|
32
|
+
self.store_messages = store_messages
|
|
33
|
+
self.previous_response_id = previous_response_id
|
|
34
|
+
self.use_encrypted_content = use_encrypted_content
|
|
35
|
+
self.temperature = temperature
|
|
36
|
+
self.top_p = top_p
|
|
37
|
+
self.max_output_tokens = max_output_tokens
|
|
38
|
+
self.reasoning_effort = reasoning_effort
|
|
39
|
+
self.tools = tools
|
|
40
|
+
self.tool_choice = tool_choice
|
|
41
|
+
self.extra_body = dict(extra_body or {})
|
|
42
|
+
self.messages: List[Message] = []
|
|
43
|
+
|
|
44
|
+
def append(self, message: Union[Message, Response]) -> "Chat":
|
|
45
|
+
if isinstance(message, Response):
|
|
46
|
+
self.previous_response_id = message.id or self.previous_response_id
|
|
47
|
+
for item in message.output:
|
|
48
|
+
self.messages.append(item)
|
|
49
|
+
else:
|
|
50
|
+
self.messages.append(message)
|
|
51
|
+
return self
|
|
52
|
+
|
|
53
|
+
def _build_body(self, stream: bool) -> Dict[str, Any]:
|
|
54
|
+
body: Dict[str, Any] = {
|
|
55
|
+
"model": self.model,
|
|
56
|
+
"messages": self.messages,
|
|
57
|
+
"stream": stream,
|
|
58
|
+
"store": self.store_messages,
|
|
59
|
+
}
|
|
60
|
+
if self.previous_response_id is not None:
|
|
61
|
+
body["previous_response_id"] = self.previous_response_id
|
|
62
|
+
if self.temperature is not None:
|
|
63
|
+
body["temperature"] = self.temperature
|
|
64
|
+
if self.top_p is not None:
|
|
65
|
+
body["top_p"] = self.top_p
|
|
66
|
+
if self.max_output_tokens is not None:
|
|
67
|
+
body["max_output_tokens"] = self.max_output_tokens
|
|
68
|
+
if self.reasoning_effort is not None:
|
|
69
|
+
body["reasoning_effort"] = self.reasoning_effort
|
|
70
|
+
if self.tools is not None:
|
|
71
|
+
body["tools"] = self.tools
|
|
72
|
+
if self.tool_choice is not None:
|
|
73
|
+
body["tool_choice"] = self.tool_choice
|
|
74
|
+
if self.use_encrypted_content:
|
|
75
|
+
body["include"] = ["reasoning.encrypted_content"]
|
|
76
|
+
body.update(self.extra_body)
|
|
77
|
+
return body
|
|
78
|
+
|
|
79
|
+
def sample(self) -> Response:
|
|
80
|
+
data = self._transport.request("POST", self._path, json_body=self._build_body(stream=False))
|
|
81
|
+
response = Response.from_dict(data)
|
|
82
|
+
if response.id:
|
|
83
|
+
self.previous_response_id = response.id
|
|
84
|
+
return response
|
|
85
|
+
|
|
86
|
+
def stream(self) -> Tuple[Response, Iterator[ResponseChunk]]:
|
|
87
|
+
accumulator = Response(
|
|
88
|
+
id=None, model=self.model, content="", reasoning_content=None,
|
|
89
|
+
output=[], function_calls=[], usage=None, raw={}, # type: ignore[arg-type]
|
|
90
|
+
)
|
|
91
|
+
events = self._transport.stream("POST", self._path, json_body=self._build_body(stream=True))
|
|
92
|
+
|
|
93
|
+
def _generator() -> Iterator[ResponseChunk]:
|
|
94
|
+
content_parts: List[str] = []
|
|
95
|
+
reasoning_parts: List[str] = []
|
|
96
|
+
for event in events:
|
|
97
|
+
chunk = ResponseChunk.from_event(event)
|
|
98
|
+
if chunk.content:
|
|
99
|
+
content_parts.append(chunk.content)
|
|
100
|
+
accumulator.content = "".join(content_parts)
|
|
101
|
+
if chunk.reasoning_content:
|
|
102
|
+
reasoning_parts.append(chunk.reasoning_content)
|
|
103
|
+
accumulator.reasoning_content = "".join(reasoning_parts)
|
|
104
|
+
if chunk.usage is not None:
|
|
105
|
+
accumulator.usage = chunk.usage
|
|
106
|
+
if chunk.function_call is not None:
|
|
107
|
+
accumulator.function_calls.append(chunk.function_call)
|
|
108
|
+
yield chunk
|
|
109
|
+
|
|
110
|
+
return accumulator, _generator()
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ChatClient:
|
|
114
|
+
def __init__(self, transport: SyncTransport, *, path: str = "/chat/completions") -> None:
|
|
115
|
+
self._transport = transport
|
|
116
|
+
self._path = path
|
|
117
|
+
|
|
118
|
+
def create(
|
|
119
|
+
self,
|
|
120
|
+
*,
|
|
121
|
+
model: str,
|
|
122
|
+
store_messages: bool = True,
|
|
123
|
+
previous_response_id: Optional[str] = None,
|
|
124
|
+
use_encrypted_content: bool = False,
|
|
125
|
+
temperature: Optional[float] = None,
|
|
126
|
+
top_p: Optional[float] = None,
|
|
127
|
+
max_output_tokens: Optional[int] = None,
|
|
128
|
+
reasoning_effort: Optional[str] = None,
|
|
129
|
+
tools: Optional[List[Any]] = None,
|
|
130
|
+
tool_choice: Optional[Any] = None,
|
|
131
|
+
**extra_body: Any,
|
|
132
|
+
) -> Chat:
|
|
133
|
+
return Chat(
|
|
134
|
+
self._transport,
|
|
135
|
+
model=model,
|
|
136
|
+
path=self._path,
|
|
137
|
+
store_messages=store_messages,
|
|
138
|
+
previous_response_id=previous_response_id,
|
|
139
|
+
use_encrypted_content=use_encrypted_content,
|
|
140
|
+
temperature=temperature,
|
|
141
|
+
top_p=top_p,
|
|
142
|
+
max_output_tokens=max_output_tokens,
|
|
143
|
+
reasoning_effort=reasoning_effort,
|
|
144
|
+
tools=tools,
|
|
145
|
+
tool_choice=tool_choice,
|
|
146
|
+
extra_body=extra_body or None,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
def get_stored_completion(self, response_id: str) -> Response:
|
|
150
|
+
data = self._transport.request("GET", f"{self._path}/{response_id}")
|
|
151
|
+
return Response.from_dict(data)
|
|
152
|
+
|
|
153
|
+
def delete_stored_completion(self, response_id: str) -> Dict[str, Any]:
|
|
154
|
+
return self._transport.request("DELETE", f"{self._path}/{response_id}")
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, Union
|
|
4
|
+
|
|
5
|
+
Content = Union[str, Dict[str, Any]]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def text(value: str) -> Dict[str, Any]:
|
|
9
|
+
return {"type": "text", "text": value}
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def image(url: str) -> Dict[str, Any]:
|
|
13
|
+
return {"type": "image", "image": url}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def video(url: str) -> Dict[str, Any]:
|
|
17
|
+
return {"type": "video", "video": url}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def file(value: str, name: str | None = None) -> Dict[str, Any]:
|
|
21
|
+
part: Dict[str, Any] = {"type": "file", "file": value}
|
|
22
|
+
if name is not None:
|
|
23
|
+
part["name"] = name
|
|
24
|
+
return part
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _message(role: str, *parts: Content) -> Dict[str, Any]:
|
|
28
|
+
if len(parts) == 1 and isinstance(parts[0], str):
|
|
29
|
+
return {"role": role, "content": parts[0]}
|
|
30
|
+
content = [text(p) if isinstance(p, str) else p for p in parts]
|
|
31
|
+
return {"role": role, "content": content}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def system(*parts: Content) -> Dict[str, Any]:
|
|
35
|
+
return _message("system", *parts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def user(*parts: Content) -> Dict[str, Any]:
|
|
39
|
+
return _message("user", *parts)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assistant(*parts: Content) -> Dict[str, Any]:
|
|
43
|
+
return _message("assistant", *parts)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def tool_result(call_id: str, output: str) -> Dict[str, Any]:
|
|
47
|
+
return {"type": "function_call_output", "call_id": call_id, "output": output}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Any, Dict, List, Mapping, Optional
|
|
5
|
+
|
|
6
|
+
Role = str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Usage:
|
|
11
|
+
input_tokens: int = 0
|
|
12
|
+
output_tokens: int = 0
|
|
13
|
+
total_tokens: int = 0
|
|
14
|
+
cost_in_usd_ticks: Optional[int] = None
|
|
15
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> "Usage":
|
|
19
|
+
if not data:
|
|
20
|
+
return cls()
|
|
21
|
+
return cls(
|
|
22
|
+
input_tokens=int(data.get("input_tokens", 0) or 0),
|
|
23
|
+
output_tokens=int(data.get("output_tokens", 0) or 0),
|
|
24
|
+
total_tokens=int(data.get("total_tokens", 0) or 0),
|
|
25
|
+
cost_in_usd_ticks=data.get("cost_in_usd_ticks"),
|
|
26
|
+
raw=dict(data),
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class FunctionCall:
|
|
32
|
+
name: str
|
|
33
|
+
arguments: str
|
|
34
|
+
call_id: Optional[str] = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Response:
|
|
39
|
+
id: Optional[str]
|
|
40
|
+
model: Optional[str]
|
|
41
|
+
content: str
|
|
42
|
+
reasoning_content: Optional[str]
|
|
43
|
+
output: List[Dict[str, Any]]
|
|
44
|
+
function_calls: List[FunctionCall]
|
|
45
|
+
usage: Usage
|
|
46
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_dict(cls, data: Mapping[str, Any]) -> "Response":
|
|
50
|
+
output = list(data.get("output") or [])
|
|
51
|
+
content = _extract_output_text(output)
|
|
52
|
+
reasoning = _extract_reasoning(output)
|
|
53
|
+
fcs = [
|
|
54
|
+
FunctionCall(name=fc.get("name", ""), arguments=fc.get("arguments", ""), call_id=fc.get("call_id"))
|
|
55
|
+
for fc in (data.get("function_calls") or [])
|
|
56
|
+
]
|
|
57
|
+
return cls(
|
|
58
|
+
id=data.get("id"),
|
|
59
|
+
model=data.get("model"),
|
|
60
|
+
content=content,
|
|
61
|
+
reasoning_content=reasoning,
|
|
62
|
+
output=output,
|
|
63
|
+
function_calls=fcs,
|
|
64
|
+
usage=Usage.from_dict(data.get("usage")),
|
|
65
|
+
raw=dict(data),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __str__(self) -> str:
|
|
69
|
+
return self.content
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class ResponseChunk:
|
|
74
|
+
content: str = ""
|
|
75
|
+
reasoning_content: str = ""
|
|
76
|
+
type: Optional[str] = None
|
|
77
|
+
status: Optional[str] = None
|
|
78
|
+
usage: Optional[Usage] = None
|
|
79
|
+
function_call: Optional[FunctionCall] = None
|
|
80
|
+
raw: Dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def from_event(cls, event: Mapping[str, Any]) -> "ResponseChunk":
|
|
84
|
+
chunk = cls(type=event.get("type"), raw=dict(event))
|
|
85
|
+
if "usage" in event and isinstance(event["usage"], Mapping):
|
|
86
|
+
chunk.usage = Usage.from_dict(event["usage"])
|
|
87
|
+
if event.get("type") == "reasoning_delta":
|
|
88
|
+
chunk.reasoning_content = event.get("content", "") or ""
|
|
89
|
+
elif event.get("type") == "function_call":
|
|
90
|
+
chunk.function_call = FunctionCall(
|
|
91
|
+
name=event.get("name", ""), arguments=event.get("arguments", "")
|
|
92
|
+
)
|
|
93
|
+
elif event.get("type") in ("tool_update", "search_status", "search_query", "search_started"):
|
|
94
|
+
chunk.status = event.get("status")
|
|
95
|
+
choices = event.get("choices")
|
|
96
|
+
if isinstance(choices, list) and choices:
|
|
97
|
+
delta = choices[0].get("delta") or {}
|
|
98
|
+
chunk.content = delta.get("content", "") or ""
|
|
99
|
+
return chunk
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _extract_output_text(output: List[Mapping[str, Any]]) -> str:
|
|
103
|
+
parts: List[str] = []
|
|
104
|
+
for item in output:
|
|
105
|
+
if item.get("type") == "message":
|
|
106
|
+
for block in item.get("content") or []:
|
|
107
|
+
if block.get("type") == "output_text" and block.get("text"):
|
|
108
|
+
parts.append(block["text"])
|
|
109
|
+
elif item.get("type") == "output_text" and item.get("text"):
|
|
110
|
+
parts.append(item["text"])
|
|
111
|
+
elif item.get("type") == "text" and item.get("text"):
|
|
112
|
+
parts.append(item["text"])
|
|
113
|
+
return "".join(parts)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _extract_reasoning(output: List[Mapping[str, Any]]) -> Optional[str]:
|
|
117
|
+
for item in output:
|
|
118
|
+
if item.get("type") == "reasoning":
|
|
119
|
+
summary = item.get("summary")
|
|
120
|
+
if isinstance(summary, list):
|
|
121
|
+
texts = [s.get("text", "") for s in summary if isinstance(s, Mapping)]
|
|
122
|
+
joined = "".join(texts)
|
|
123
|
+
if joined:
|
|
124
|
+
return joined
|
|
125
|
+
if item.get("encrypted_content"):
|
|
126
|
+
return item["encrypted_content"]
|
|
127
|
+
return None
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "caether"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the CaetherAI API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "CaetherAI" }]
|
|
13
|
+
keywords = ["caether", "caetherai", "caether-ai", "ai", "llm", "sdk", "api"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Programming Language :: Python :: 3.8",
|
|
21
|
+
"Programming Language :: Python :: 3.9",
|
|
22
|
+
"Programming Language :: Python :: 3.10",
|
|
23
|
+
"Programming Language :: Python :: 3.11",
|
|
24
|
+
"Programming Language :: Python :: 3.12",
|
|
25
|
+
"Programming Language :: Python :: 3.13",
|
|
26
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"httpx>=0.24.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[project.optional-dependencies]
|
|
33
|
+
dev = ["pytest>=7.0", "pytest-asyncio>=0.21", "respx>=0.20"]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://caether.ai"
|
|
37
|
+
Documentation = "https://docs.caether.ai"
|
|
38
|
+
|
|
39
|
+
[tool.hatch.build.targets.wheel]
|
|
40
|
+
packages = ["caether"]
|