caetherai 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,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.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: caetherai
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,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 caetherai
40
+ ```
41
+
42
+ ## Quickstart
43
+
44
+ ```python
45
+ import os
46
+ from caetherai import Client
47
+ from caetherai.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.
@@ -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 caetherai
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ ```python
15
+ import os
16
+ from caetherai import Client
17
+ from caetherai.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,10 @@
1
+ from __future__ import annotations
2
+
3
+ DEFAULT_API_HOST = "api.caether.ai"
4
+ DEFAULT_BASE_URL = f"https://{DEFAULT_API_HOST}"
5
+
6
+ DEFAULT_TIMEOUT = 3600.0
7
+ DEFAULT_MAX_RETRIES = 2
8
+
9
+ USER_AGENT = "caetherai-python"
10
+ SDK_VERSION = "0.1.0"
@@ -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,3 @@
1
+ from .._client import AsyncClient
2
+
3
+ __all__ = ["AsyncClient"]
@@ -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 = "caetherai"
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", "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 = ["caetherai"]