agentum-cloud-sdk 0.1.62__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,51 @@
1
+ """agentum-cloud SDK — типизированный async-клиент над REST API ``/v1``.
2
+
3
+ Один контракт для всех клиентов (бот, MCP, сторонние проекты). Пример::
4
+
5
+ async with AgentumClient(base_url="http://host:8083", api_key="...") as cloud:
6
+ obj = await cloud.upload("doc.pdf", data, owner_id="tg:42")
7
+ ready = await cloud.wait_until_ready(obj.id, owner_id="tg:42")
8
+ answer = await cloud.ask("когда отпуск?", owner_id="tg:42")
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from agentum_cloud.client import AgentumClient
14
+ from agentum_cloud.errors import AgentumAPIError, AgentumError, AgentumTimeoutError
15
+ from agentum_cloud.models import (
16
+ AskAnswer,
17
+ Citation,
18
+ Collection,
19
+ ContentUrl,
20
+ Enrichment,
21
+ MeterCost,
22
+ ObjectDetail,
23
+ ObjectSummary,
24
+ OwnerUsage,
25
+ SearchHit,
26
+ SummaryOut,
27
+ UploadResult,
28
+ UsageBreakdown,
29
+ UsageSummary,
30
+ )
31
+
32
+ __all__ = [
33
+ "AgentumClient",
34
+ "AgentumError",
35
+ "AgentumAPIError",
36
+ "AgentumTimeoutError",
37
+ "AskAnswer",
38
+ "Citation",
39
+ "Collection",
40
+ "ContentUrl",
41
+ "Enrichment",
42
+ "MeterCost",
43
+ "ObjectDetail",
44
+ "ObjectSummary",
45
+ "OwnerUsage",
46
+ "SearchHit",
47
+ "SummaryOut",
48
+ "UploadResult",
49
+ "UsageBreakdown",
50
+ "UsageSummary",
51
+ ]
@@ -0,0 +1,373 @@
1
+ """Async-клиент над agentum-cloud ``/v1``.
2
+
3
+ Тонкая обёртка httpx: Bearer-ключ, опциональный ``owner_id`` (X-Owner-Id, бот мапит
4
+ tg-юзера), ретраи на 429/5xx с экспоненциальным backoff, типизированные ответы.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import re
11
+ from types import TracebackType
12
+ from typing import Any
13
+
14
+ import httpx
15
+
16
+ from agentum_cloud.errors import AgentumAPIError, AgentumTimeoutError
17
+ from agentum_cloud.models import (
18
+ AskAnswer,
19
+ Collection,
20
+ ContentUrl,
21
+ ObjectDetail,
22
+ ObjectSummary,
23
+ SearchHit,
24
+ SummaryOut,
25
+ UploadResult,
26
+ UsageBreakdown,
27
+ UsageSummary,
28
+ )
29
+
30
+ # 504 НЕ ретраим: это дедлайн сервера /ask (ask_deadline_seconds) — повтор лишь сожжёт
31
+ # токены и время на том же запросе; пользователю показываем сообщение «не успел».
32
+ _RETRY_STATUSES = frozenset({429, 500, 502, 503})
33
+ _BACKOFF_BASE = 0.5
34
+ _BACKOFF_CAP = 8.0
35
+ _TERMINAL_STATUSES = frozenset({"ready", "failed"})
36
+ # read=75 заведомо больше серверного дедлайна /ask (settings.ask_deadline_seconds=60):
37
+ # клиент не отваливается раньше, чем сервер вернёт корректный ответ/типизированную 504.
38
+ _DEFAULT_TIMEOUT = httpx.Timeout(connect=5.0, read=75.0, write=10.0, pool=5.0)
39
+ # Транспортные ошибки «соединение не установлено» — запрос гарантированно не дошёл
40
+ # до сервера, поэтому ретраить безопасно даже неидемпотентный POST.
41
+ _CONNECT_ERRORS = (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout)
42
+ # Тот же контракт, что и сервер (deps._OWNER_RE) — ловим мусор до отправки заголовка.
43
+ _OWNER_RE = re.compile(r"\A[A-Za-z0-9_:.-]{1,64}\Z")
44
+
45
+
46
+ class AgentumClient:
47
+ """Клиент REST API. Используй как async-контекст-менеджер."""
48
+
49
+ def __init__(
50
+ self,
51
+ *,
52
+ base_url: str,
53
+ api_key: str,
54
+ owner_id: str | None = None,
55
+ timeout: float | httpx.Timeout | None = None,
56
+ max_attempts: int = 4,
57
+ client: httpx.AsyncClient | None = None,
58
+ ) -> None:
59
+ self._base_url = base_url.rstrip("/")
60
+ self._api_key = api_key
61
+ self._owner_id = owner_id
62
+ self._max_attempts = max(1, max_attempts)
63
+ # Внешний client (для тестов/переиспользования) не закрываем сами.
64
+ # trust_env=False — детерминированный SDK: не подхватываем прокси из окружения
65
+ # (HTTP_PROXY/ALL_PROXY); нужен прокси — передай готовый httpx.AsyncClient.
66
+ self._owns_client = client is None
67
+ self._client = client or httpx.AsyncClient(
68
+ base_url=self._base_url,
69
+ timeout=timeout if timeout is not None else _DEFAULT_TIMEOUT,
70
+ trust_env=False,
71
+ )
72
+
73
+ async def __aenter__(self) -> AgentumClient:
74
+ return self
75
+
76
+ async def __aexit__(
77
+ self,
78
+ exc_type: type[BaseException] | None,
79
+ exc: BaseException | None,
80
+ tb: TracebackType | None,
81
+ ) -> None:
82
+ await self.aclose()
83
+
84
+ async def aclose(self) -> None:
85
+ if self._owns_client:
86
+ await self._client.aclose()
87
+
88
+ def _headers(self, owner_id: str | None) -> dict[str, str]:
89
+ headers = {"Authorization": f"Bearer {self._api_key}"}
90
+ effective = owner_id or self._owner_id
91
+ if effective:
92
+ if not _OWNER_RE.fullmatch(effective):
93
+ raise ValueError(f"Некорректный owner_id: {effective!r}")
94
+ headers["X-Owner-Id"] = effective
95
+ return headers
96
+
97
+ async def _request(
98
+ self,
99
+ method: str,
100
+ path: str,
101
+ *,
102
+ owner_id: str | None = None,
103
+ idempotent: bool = False,
104
+ **kwargs: Any,
105
+ ) -> httpx.Response:
106
+ last_exc: Exception | None = None
107
+ for attempt in range(1, self._max_attempts + 1):
108
+ try:
109
+ resp = await self._client.request(method, path, headers=self._headers(owner_id), **kwargs)
110
+ except httpx.TransportError as exc:
111
+ last_exc = exc
112
+ # Соединение не установлено → запрос не дошёл, ретраим любой метод. Иначе
113
+ # (read/write — байты уже могли уйти) ретраим только идемпотентные: дорогой
114
+ # неидемпотентный POST (/ask, /objects) по таймауту ответа НЕ перезапускаем.
115
+ connect_failed = isinstance(exc, _CONNECT_ERRORS)
116
+ if (not connect_failed and not idempotent) or attempt == self._max_attempts:
117
+ raise AgentumAPIError(0, f"Сеть недоступна: {exc}") from exc
118
+ await asyncio.sleep(_backoff(attempt))
119
+ continue
120
+
121
+ if resp.status_code in _RETRY_STATUSES and attempt < self._max_attempts:
122
+ await asyncio.sleep(_backoff(attempt, resp.headers.get("Retry-After")))
123
+ continue
124
+ if resp.status_code >= 400:
125
+ raise AgentumAPIError(resp.status_code, _error_message(resp), body=resp.text[:500])
126
+ return resp
127
+
128
+ # Недостижимо при max_attempts>=1, но успокаиваем тайпчекер.
129
+ raise AgentumAPIError(0, f"Запрос не удался: {last_exc}")
130
+
131
+ async def upload(
132
+ self,
133
+ filename: str,
134
+ data: bytes,
135
+ *,
136
+ mime: str | None = None,
137
+ owner_id: str | None = None,
138
+ ) -> UploadResult:
139
+ files = {"file": (filename, data, mime or "application/octet-stream")}
140
+ # idempotent=False (по умолчанию): не плодим дубли загрузки по read-timeout.
141
+ resp = await self._request("POST", "/v1/objects", owner_id=owner_id, files=files)
142
+ return UploadResult.from_dict(resp.json())
143
+
144
+ async def get_object(self, object_id: str, *, owner_id: str | None = None) -> ObjectDetail:
145
+ resp = await self._request("GET", f"/v1/objects/{object_id}", owner_id=owner_id, idempotent=True)
146
+ return ObjectDetail.from_dict(resp.json())
147
+
148
+ async def list_objects(
149
+ self,
150
+ *,
151
+ limit: int | None = None,
152
+ offset: int | None = None,
153
+ doc_type: str | None = None,
154
+ owner_id: str | None = None,
155
+ ) -> list[ObjectSummary]:
156
+ """Список объектов владельца (новые сверху)."""
157
+ params: dict[str, Any] = {}
158
+ if limit:
159
+ params["limit"] = limit
160
+ if offset:
161
+ params["offset"] = offset
162
+ if doc_type:
163
+ params["doc_type"] = doc_type
164
+ resp = await self._request("GET", "/v1/objects", owner_id=owner_id, params=params or None)
165
+ return [ObjectSummary.from_dict(it) for it in resp.json().get("items", [])]
166
+
167
+ async def get_content(self, object_id: str, *, owner_id: str | None = None) -> ContentUrl:
168
+ resp = await self._request(
169
+ "GET", f"/v1/objects/{object_id}/content", owner_id=owner_id, idempotent=True
170
+ )
171
+ return ContentUrl.from_dict(resp.json())
172
+
173
+ async def download_url(self, url: str) -> bytes:
174
+ """Качает байты по (presigned) URL. Auth-заголовок НЕ шлём — ссылка самодостаточна."""
175
+ try:
176
+ resp = await self._client.get(url, follow_redirects=True)
177
+ except httpx.TransportError as exc:
178
+ raise AgentumAPIError(0, f"Сеть недоступна: {exc}") from exc
179
+ if resp.status_code >= 400:
180
+ raise AgentumAPIError(resp.status_code, "Не удалось скачать файл", body=resp.text[:300])
181
+ return resp.content
182
+
183
+ async def get_usage(self, *, days: int | None = None, owner_id: str | None = None) -> UsageSummary:
184
+ """Расход текущего владельца (или ``owner_id``): стоимость ₽ + разбивка по статьям."""
185
+ params = {"days": days} if days else None
186
+ resp = await self._request("GET", "/v1/usage", owner_id=owner_id, params=params)
187
+ return UsageSummary.from_dict(resp.json())
188
+
189
+ async def get_usage_breakdown(
190
+ self, *, days: int | None = None, owner_id: str | None = None
191
+ ) -> UsageBreakdown:
192
+ """Расход по всем юзерам тенанта (только админ; иначе AgentumAPIError 403)."""
193
+ params = {"days": days} if days else None
194
+ resp = await self._request("GET", "/v1/usage/breakdown", owner_id=owner_id, params=params)
195
+ return UsageBreakdown.from_dict(resp.json())
196
+
197
+ async def classify_intent(self, query: str, *, owner_id: str | None = None) -> tuple[str, str, str]:
198
+ """Намерение свободной фразы: ``(action, target, lang)``.
199
+
200
+ ``action`` ∈ {send_file, answer, delete, translate}; ``lang`` — целевой язык
201
+ для translate (для остальных — "").
202
+ """
203
+ resp = await self._request("POST", "/v1/intent", owner_id=owner_id, json={"query": query})
204
+ data = resp.json()
205
+ return (
206
+ str(data.get("action") or "answer"),
207
+ str(data.get("target") or ""),
208
+ str(data.get("lang") or ""),
209
+ )
210
+
211
+ async def transcribe(
212
+ self,
213
+ filename: str,
214
+ data: bytes,
215
+ *,
216
+ mime: str | None = None,
217
+ owner_id: str | None = None,
218
+ ) -> str:
219
+ """Распознаёт речь из аудио в текст (без сохранения в облаке). Возвращает текст ("" — нет речи)."""
220
+ files = {"file": (filename, data, mime or "audio/ogg")}
221
+ resp = await self._request("POST", "/v1/transcribe", owner_id=owner_id, files=files)
222
+ return str(resp.json().get("text", ""))
223
+
224
+ async def search(
225
+ self,
226
+ query: str,
227
+ *,
228
+ doc_type: str | None = None,
229
+ top_k: int | None = None,
230
+ owner_id: str | None = None,
231
+ ) -> list[SearchHit]:
232
+ params: dict[str, Any] = {"q": query}
233
+ if doc_type:
234
+ params["doc_type"] = doc_type
235
+ if top_k:
236
+ params["top_k"] = top_k
237
+ resp = await self._request("GET", "/v1/search", owner_id=owner_id, idempotent=True, params=params)
238
+ return [SearchHit.from_dict(h) for h in resp.json().get("hits", [])]
239
+
240
+ async def ask(
241
+ self,
242
+ query: str,
243
+ *,
244
+ doc_type: str | None = None,
245
+ top_k: int | None = None,
246
+ owner_id: str | None = None,
247
+ ) -> AskAnswer:
248
+ body: dict[str, Any] = {"query": query}
249
+ if doc_type:
250
+ body["doc_type"] = doc_type
251
+ if top_k:
252
+ body["top_k"] = top_k
253
+ # idempotent=False (по умолчанию): дорогой LLM-вызов не перезапускаем по read-timeout;
254
+ # сервер сам ограничен дедлайном (ask_deadline_seconds) и вернёт типизированную ошибку.
255
+ resp = await self._request("POST", "/v1/ask", owner_id=owner_id, json=body)
256
+ return AskAnswer.from_dict(resp.json())
257
+
258
+ async def wait_until_ready(
259
+ self,
260
+ object_id: str,
261
+ *,
262
+ owner_id: str | None = None,
263
+ timeout: float = 120.0, # noqa: ASYNC109 — это бюджет поллинга, не single-shot asyncio.timeout
264
+ interval: float = 2.0,
265
+ ) -> ObjectDetail:
266
+ """Поллит статус объекта до ``ready``/``failed``. Бросает AgentumTimeoutError по таймауту."""
267
+ loop = asyncio.get_running_loop()
268
+ deadline = loop.time() + timeout
269
+ while True:
270
+ detail = await self.get_object(object_id, owner_id=owner_id)
271
+ if detail.status in _TERMINAL_STATUSES:
272
+ return detail
273
+ remaining = deadline - loop.time()
274
+ if remaining <= 0:
275
+ raise AgentumTimeoutError(f"Объект {object_id} не готов за {timeout:.0f}с")
276
+ await asyncio.sleep(min(interval, remaining))
277
+
278
+ # --- Папки/коллекции и удаление (Ф8, Dev A) ----------------------------
279
+
280
+ async def delete_object(self, object_id: str, *, owner_id: str | None = None) -> None:
281
+ """Удаляет объект (файл) безвозвратно. 204 — успех."""
282
+ await self._request("DELETE", f"/v1/objects/{object_id}", owner_id=owner_id)
283
+
284
+ async def create_collection(
285
+ self, name: str, *, parent_id: str | None = None, owner_id: str | None = None
286
+ ) -> Collection:
287
+ """Создаёт папку. ``parent_id`` — родитель (None для корня)."""
288
+ body: dict[str, Any] = {"name": name}
289
+ if parent_id is not None:
290
+ body["parent_id"] = parent_id
291
+ resp = await self._request("POST", "/v1/collections", owner_id=owner_id, json=body)
292
+ return Collection.from_dict(resp.json())
293
+
294
+ async def list_collections(self, *, owner_id: str | None = None) -> list[Collection]:
295
+ """Плоский список папок владельца (дерево строится по ``parent_id``)."""
296
+ resp = await self._request("GET", "/v1/collections", owner_id=owner_id)
297
+ return [Collection.from_dict(it) for it in resp.json().get("items", [])]
298
+
299
+ async def rename_collection(
300
+ self, collection_id: str, name: str, *, owner_id: str | None = None
301
+ ) -> Collection:
302
+ """Переименовывает папку."""
303
+ resp = await self._request(
304
+ "PATCH", f"/v1/collections/{collection_id}", owner_id=owner_id, json={"name": name}
305
+ )
306
+ return Collection.from_dict(resp.json())
307
+
308
+ async def move_collection(
309
+ self, collection_id: str, parent_id: str | None, *, owner_id: str | None = None
310
+ ) -> Collection:
311
+ """Переносит папку под нового родителя (``parent_id=None`` — в корень)."""
312
+ resp = await self._request(
313
+ "PATCH",
314
+ f"/v1/collections/{collection_id}",
315
+ owner_id=owner_id,
316
+ json={"parent_id": parent_id},
317
+ )
318
+ return Collection.from_dict(resp.json())
319
+
320
+ async def delete_collection(self, collection_id: str, *, owner_id: str | None = None) -> None:
321
+ """Удаляет папку (под-папки всплывают в корень, файлы — «без папки»)."""
322
+ await self._request("DELETE", f"/v1/collections/{collection_id}", owner_id=owner_id)
323
+
324
+ async def move_object(self, collection_id: str, object_id: str, *, owner_id: str | None = None) -> None:
325
+ """Перемещает файл в папку. 204 — успех."""
326
+ await self._request("POST", f"/v1/collections/{collection_id}/objects/{object_id}", owner_id=owner_id)
327
+
328
+ # --- Ф9 (Dev B): контент-функции ---------------------------------------
329
+
330
+ async def get_summary(self, object_id: str, *, owner_id: str | None = None) -> SummaryOut:
331
+ """Содержание объекта (Ф9): метаданные из обогащения. ``pending`` — статус без полей."""
332
+ resp = await self._request(
333
+ "GET", f"/v1/objects/{object_id}/summary", owner_id=owner_id, idempotent=True
334
+ )
335
+ return SummaryOut.from_dict(resp.json())
336
+
337
+ async def translate(
338
+ self, object_id: str, target_lang: str, *, owner_id: str | None = None
339
+ ) -> tuple[str, str]:
340
+ """Перевод объекта на ``target_lang`` → новый объект. Возвращает ``(object_id, status)``.
341
+
342
+ ``idempotent=False``: дорогой LLM-вызов не перезапускаем по read-timeout (дедуп
343
+ нового объекта по ``content_hash`` на сервере всё равно защищает от дублей).
344
+ """
345
+ resp = await self._request(
346
+ "POST",
347
+ f"/v1/objects/{object_id}/translate",
348
+ owner_id=owner_id,
349
+ json={"target_lang": target_lang},
350
+ )
351
+ data = resp.json()
352
+ return str(data.get("object_id") or ""), str(data.get("status") or "")
353
+
354
+
355
+ def _backoff(attempt: int, retry_after: str | None = None) -> float:
356
+ if retry_after:
357
+ try:
358
+ return min(float(retry_after), _BACKOFF_CAP)
359
+ except ValueError:
360
+ pass
361
+ return min(_BACKOFF_BASE * (2 ** (attempt - 1)), _BACKOFF_CAP)
362
+
363
+
364
+ def _error_message(resp: httpx.Response) -> str:
365
+ try:
366
+ payload = resp.json()
367
+ except ValueError:
368
+ return resp.reason_phrase or "ошибка"
369
+ if isinstance(payload, dict):
370
+ detail = payload.get("detail") or payload.get("error") or payload.get("message")
371
+ if isinstance(detail, str):
372
+ return detail
373
+ return resp.reason_phrase or "ошибка"
@@ -0,0 +1,21 @@
1
+ """Ошибки SDK."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class AgentumError(Exception):
7
+ """Базовая ошибка SDK."""
8
+
9
+
10
+ class AgentumAPIError(AgentumError):
11
+ """Не-2xx ответ от API. Несёт статус и тело для диагностики у клиента."""
12
+
13
+ def __init__(self, status_code: int, message: str, *, body: str = "") -> None:
14
+ super().__init__(f"API {status_code}: {message}")
15
+ self.status_code = status_code
16
+ self.message = message
17
+ self.body = body
18
+
19
+
20
+ class AgentumTimeoutError(AgentumError):
21
+ """Объект не дошёл до терминального статуса за отведённое время (wait_until_ready)."""
@@ -0,0 +1,301 @@
1
+ """Типизированные DTO ответов API (зеркало backend/app/schemas).
2
+
3
+ Лёгкие ``frozen``-датаклассы без pydantic — SDK не тянет лишних зависимостей.
4
+ Каждый класс умеет собираться из JSON-словаря через ``from_dict``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass, field
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class UploadResult:
15
+ id: str
16
+ status: str
17
+ duplicate: bool = False
18
+
19
+ @classmethod
20
+ def from_dict(cls, d: dict[str, Any]) -> UploadResult:
21
+ return cls(id=str(d["id"]), status=str(d["status"]), duplicate=bool(d.get("duplicate", False)))
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Enrichment:
26
+ title: str = ""
27
+ summary: str = ""
28
+ description: str = ""
29
+ keywords: list[str] = field(default_factory=list)
30
+ doc_type: str = "other"
31
+ language: str = ""
32
+ entities: dict[str, Any] = field(default_factory=dict)
33
+ model: str = ""
34
+
35
+ @classmethod
36
+ def from_dict(cls, d: dict[str, Any]) -> Enrichment:
37
+ return cls(
38
+ title=str(d.get("title", "")),
39
+ summary=str(d.get("summary", "")),
40
+ description=str(d.get("description", "")),
41
+ keywords=list(d.get("keywords") or []),
42
+ doc_type=str(d.get("doc_type", "other")),
43
+ language=str(d.get("language", "")),
44
+ entities=dict(d.get("entities") or {}),
45
+ model=str(d.get("model", "")),
46
+ )
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class SummaryOut:
51
+ """Карточка содержания объекта (Ф9): метаданные + статус. Зеркало backend ``SummaryOut``."""
52
+
53
+ object_id: str
54
+ status: str
55
+ filename: str = ""
56
+ title: str = ""
57
+ summary: str = ""
58
+ description: str = ""
59
+ doc_type: str = "other"
60
+ keywords: list[str] = field(default_factory=list)
61
+ language: str = ""
62
+ entities: dict[str, Any] = field(default_factory=dict)
63
+
64
+ @property
65
+ def is_ready(self) -> bool:
66
+ return self.status == "ready"
67
+
68
+ @classmethod
69
+ def from_dict(cls, d: dict[str, Any]) -> SummaryOut:
70
+ return cls(
71
+ object_id=str(d["object_id"]),
72
+ status=str(d.get("status", "")),
73
+ filename=str(d.get("filename", "")),
74
+ title=str(d.get("title", "")),
75
+ summary=str(d.get("summary", "")),
76
+ description=str(d.get("description", "")),
77
+ doc_type=str(d.get("doc_type", "other")),
78
+ keywords=list(d.get("keywords") or []),
79
+ language=str(d.get("language", "")),
80
+ entities=dict(d.get("entities") or {}),
81
+ )
82
+
83
+
84
+ @dataclass(frozen=True)
85
+ class ObjectDetail:
86
+ id: str
87
+ filename: str
88
+ mime: str
89
+ size: int
90
+ kind: str
91
+ status: str
92
+ error: str | None = None
93
+ content_hash: str = ""
94
+ enrichment: Enrichment | None = None
95
+
96
+ @property
97
+ def is_ready(self) -> bool:
98
+ return self.status == "ready"
99
+
100
+ @property
101
+ def is_failed(self) -> bool:
102
+ return self.status == "failed"
103
+
104
+ @classmethod
105
+ def from_dict(cls, d: dict[str, Any]) -> ObjectDetail:
106
+ enr = d.get("enrichment")
107
+ return cls(
108
+ id=str(d["id"]),
109
+ filename=str(d.get("filename", "")),
110
+ mime=str(d.get("mime", "")),
111
+ size=int(d.get("size", 0)),
112
+ kind=str(d.get("kind", "")),
113
+ status=str(d.get("status", "")),
114
+ error=d.get("error"),
115
+ content_hash=str(d.get("content_hash", "")),
116
+ enrichment=Enrichment.from_dict(enr) if isinstance(enr, dict) else None,
117
+ )
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class SearchHit:
122
+ object_id: str
123
+ filename: str = ""
124
+ doc_type: str = "other"
125
+ title: str = ""
126
+ summary: str = ""
127
+ score: float = 0.0
128
+ snippet: str = ""
129
+
130
+ @classmethod
131
+ def from_dict(cls, d: dict[str, Any]) -> SearchHit:
132
+ return cls(
133
+ object_id=str(d["object_id"]),
134
+ filename=str(d.get("filename", "")),
135
+ doc_type=str(d.get("doc_type", "other")),
136
+ title=str(d.get("title", "")),
137
+ summary=str(d.get("summary", "")),
138
+ score=float(d.get("score", 0.0)),
139
+ snippet=str(d.get("snippet", "")),
140
+ )
141
+
142
+
143
+ @dataclass(frozen=True)
144
+ class Citation:
145
+ object_id: str
146
+ ord: int = 0
147
+ index: int = 0
148
+ filename: str = ""
149
+ title: str = ""
150
+ snippet: str = ""
151
+
152
+ @classmethod
153
+ def from_dict(cls, d: dict[str, Any]) -> Citation:
154
+ return cls(
155
+ object_id=str(d["object_id"]),
156
+ ord=int(d.get("ord", 0)),
157
+ index=int(d.get("index", 0)),
158
+ filename=str(d.get("filename", "")),
159
+ title=str(d.get("title", "")),
160
+ snippet=str(d.get("snippet", "")),
161
+ )
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class AskAnswer:
166
+ answer: str
167
+ used_context: bool = False
168
+ citations: list[Citation] = field(default_factory=list)
169
+
170
+ @classmethod
171
+ def from_dict(cls, d: dict[str, Any]) -> AskAnswer:
172
+ cites = d.get("citations") or []
173
+ return cls(
174
+ answer=str(d.get("answer", "")),
175
+ used_context=bool(d.get("used_context", False)),
176
+ citations=[Citation.from_dict(c) for c in cites if isinstance(c, dict)],
177
+ )
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class ContentUrl:
182
+ url: str
183
+ expires_in: int = 0
184
+
185
+ @classmethod
186
+ def from_dict(cls, d: dict[str, Any]) -> ContentUrl:
187
+ return cls(url=str(d["url"]), expires_in=int(d.get("expires_in", 0)))
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class ObjectSummary:
192
+ id: str
193
+ filename: str = ""
194
+ kind: str = ""
195
+ status: str = ""
196
+ doc_type: str = "other"
197
+ title: str = ""
198
+ created_at: str = ""
199
+
200
+ @classmethod
201
+ def from_dict(cls, d: dict[str, Any]) -> ObjectSummary:
202
+ return cls(
203
+ id=str(d["id"]),
204
+ filename=str(d.get("filename", "")),
205
+ kind=str(d.get("kind", "")),
206
+ status=str(d.get("status", "")),
207
+ doc_type=str(d.get("doc_type", "other")),
208
+ title=str(d.get("title", "")),
209
+ created_at=str(d.get("created_at", "")),
210
+ )
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class Collection:
215
+ """Папка/коллекция (Ф8). ``parent_id`` — родитель в дереве (None для корня)."""
216
+
217
+ id: str
218
+ name: str = ""
219
+ parent_id: str | None = None
220
+ created_at: str = ""
221
+
222
+ @classmethod
223
+ def from_dict(cls, d: dict[str, Any]) -> Collection:
224
+ parent = d.get("parent_id")
225
+ return cls(
226
+ id=str(d["id"]),
227
+ name=str(d.get("name", "")),
228
+ parent_id=str(parent) if parent else None,
229
+ created_at=str(d.get("created_at", "")),
230
+ )
231
+
232
+
233
+ @dataclass(frozen=True)
234
+ class MeterCost:
235
+ meter: str
236
+ cost_rub: float = 0.0
237
+ prompt_tokens: int = 0
238
+ total_tokens: int = 0
239
+ audio_seconds: int = 0
240
+ calls: int = 0
241
+
242
+ @classmethod
243
+ def from_dict(cls, d: dict[str, Any]) -> MeterCost:
244
+ return cls(
245
+ meter=str(d.get("meter", "")),
246
+ cost_rub=float(d.get("cost_rub", 0.0)),
247
+ prompt_tokens=int(d.get("prompt_tokens", 0)),
248
+ total_tokens=int(d.get("total_tokens", 0)),
249
+ audio_seconds=int(d.get("audio_seconds", 0)),
250
+ calls=int(d.get("calls", 0)),
251
+ )
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class UsageSummary:
256
+ owner_id: str | None = None
257
+ days: int | None = None
258
+ total_cost_rub: float = 0.0
259
+ by_meter: list[MeterCost] = field(default_factory=list)
260
+
261
+ @classmethod
262
+ def from_dict(cls, d: dict[str, Any]) -> UsageSummary:
263
+ meters = d.get("by_meter") or []
264
+ return cls(
265
+ owner_id=d.get("owner_id"),
266
+ days=d.get("days"),
267
+ total_cost_rub=float(d.get("total_cost_rub", 0.0)),
268
+ by_meter=[MeterCost.from_dict(m) for m in meters if isinstance(m, dict)],
269
+ )
270
+
271
+
272
+ @dataclass(frozen=True)
273
+ class OwnerUsage:
274
+ owner_id: str | None = None
275
+ total_cost_rub: float = 0.0
276
+ by_meter: list[MeterCost] = field(default_factory=list)
277
+
278
+ @classmethod
279
+ def from_dict(cls, d: dict[str, Any]) -> OwnerUsage:
280
+ meters = d.get("by_meter") or []
281
+ return cls(
282
+ owner_id=d.get("owner_id"),
283
+ total_cost_rub=float(d.get("total_cost_rub", 0.0)),
284
+ by_meter=[MeterCost.from_dict(m) for m in meters if isinstance(m, dict)],
285
+ )
286
+
287
+
288
+ @dataclass(frozen=True)
289
+ class UsageBreakdown:
290
+ days: int | None = None
291
+ total_cost_rub: float = 0.0
292
+ owners: list[OwnerUsage] = field(default_factory=list)
293
+
294
+ @classmethod
295
+ def from_dict(cls, d: dict[str, Any]) -> UsageBreakdown:
296
+ owners = d.get("owners") or []
297
+ return cls(
298
+ days=d.get("days"),
299
+ total_cost_rub=float(d.get("total_cost_rub", 0.0)),
300
+ owners=[OwnerUsage.from_dict(o) for o in owners if isinstance(o, dict)],
301
+ )
agentum_cloud/py.typed ADDED
File without changes
@@ -0,0 +1,104 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentum-cloud-sdk
3
+ Version: 0.1.62
4
+ Summary: Типизированный async-клиент Agentum Cloud (/v1): загрузка документов, поиск и RAG-вопросы по вашим файлам.
5
+ Project-URL: Homepage, https://cloud.agentums.ru
6
+ Project-URL: Repository, https://gitlab.basis-pro.tech/agentum-systems/agentum-cloud
7
+ Author: Agentum Systems
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agentum,ai,async,documents,httpx,llm,rag,search
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 :: Only
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.13
20
+ Requires-Dist: httpx>=0.27
21
+ Description-Content-Type: text/markdown
22
+
23
+ # agentum-cloud-sdk
24
+
25
+ Типизированный **async**-клиент для [Agentum Cloud](https://cloud.agentums.ru) — облака
26
+ ваших документов с поиском и ответами на естественном языке (RAG). Загружаете файлы
27
+ (PDF, DOCX, изображения, аудио), а затем ищете по ним и задаёте вопросы — ИИ отвечает
28
+ с ссылками на источники.
29
+
30
+ Тонкая обёртка над `httpx` поверх REST `/v1`. Полные type hints, `py.typed`.
31
+
32
+ ## Установка
33
+
34
+ ```bash
35
+ pip install agentum-cloud-sdk
36
+ ```
37
+
38
+ Обновление до свежей версии: `pip install -U agentum-cloud-sdk`.
39
+
40
+ Требуется Python 3.13+.
41
+
42
+ ## Токен
43
+
44
+ 1. Войдите в приложение [cloud.agentums.ru](https://cloud.agentums.ru) (через Яндекс).
45
+ 2. **Настройки → API-токены → Создать** — скопируйте токен `ak_…` (показывается один раз).
46
+
47
+ Токен привязан к вашему аккаунту и подписке. Держите его в секрете (как пароль);
48
+ если скомпрометирован — отзовите в настройках и создайте новый.
49
+
50
+ ## Быстрый старт
51
+
52
+ ```python
53
+ import asyncio
54
+ from agentum_cloud import AgentumClient
55
+
56
+
57
+ async def main() -> None:
58
+ async with AgentumClient(
59
+ base_url="https://api.cloud.agentums.ru",
60
+ api_key="ak_ваш_токен",
61
+ ) as cloud:
62
+ # Загрузить документ
63
+ with open("contract.pdf", "rb") as f:
64
+ obj = await cloud.upload("contract.pdf", f.read())
65
+
66
+ # Дождаться, пока документ проиндексируется
67
+ await cloud.wait_until_ready(obj.id)
68
+
69
+ # Спросить — ответ с цитатами из ваших файлов
70
+ answer = await cloud.ask("Какой срок действия договора?")
71
+ print(answer.answer)
72
+ for c in answer.citations:
73
+ print(f" источник: {c.filename}")
74
+
75
+
76
+ asyncio.run(main())
77
+ ```
78
+
79
+ ## Что умеет
80
+
81
+ | Метод | Назначение |
82
+ |-------|-----------|
83
+ | `upload(filename, data)` | загрузить документ/изображение/аудио |
84
+ | `wait_until_ready(object_id)` | дождаться окончания индексации |
85
+ | `list_objects(...)` / `get_object(id)` | список / детали файла (заголовок, summary, теги) |
86
+ | `get_content(id)` | временная ссылка на оригинал (presigned URL) |
87
+ | `search(q)` | гибридный поиск (вектор + полнотекст) по вашим файлам |
88
+ | `ask(query)` | ответ ИИ по вашим документам + цитаты-источники |
89
+ | `transcribe(filename, data)` | расшифровка аудио в текст |
90
+ | `get_usage(days=...)` | расход (₽) и разбивка по статьям |
91
+ | `delete_object(id)` | удалить файл |
92
+
93
+ Все методы — корутины; клиент — async-context-manager (`async with`).
94
+
95
+ ## Заметки
96
+
97
+ - Данные изолированы по аккаунту: токен видит только файлы вашего аккаунта. Файлы,
98
+ загруженные через [Telegram-бота](https://cloud.agentums.ru), доступны здесь же после
99
+ привязки бота к аккаунту.
100
+ - Базовый URL прода — `https://api.cloud.agentums.ru`.
101
+
102
+ ## Лицензия
103
+
104
+ MIT — см. [LICENSE](LICENSE).
@@ -0,0 +1,9 @@
1
+ agentum_cloud/__init__.py,sha256=nM_ZAPUdpGEVGivIz0ufVwMstLXUi42F6Aez5wD3sKk,1332
2
+ agentum_cloud/client.py,sha256=a6UOFDRrlk6VHALfE6D7VrzKQtfl_ywGCftO7kVBPGA,17378
3
+ agentum_cloud/errors.py,sha256=diRKsl8Rmr-MAK3xfi321Yvr1SyvwtkYDTGJmbntUOY,723
4
+ agentum_cloud/models.py,sha256=d97ZCtszlem6qBF4rzC5TdGqGL0VmdDae3Q1fRxf2GA,8872
5
+ agentum_cloud/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ agentum_cloud_sdk-0.1.62.dist-info/METADATA,sha256=D0EthZMLtyzNGnkjH7x2nr85eK7yiMudVnMpQJTyNA8,4703
7
+ agentum_cloud_sdk-0.1.62.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ agentum_cloud_sdk-0.1.62.dist-info/licenses/LICENSE,sha256=eQ7tb37mTyh-nyBz8b2-gtI2QnI-AYfmr8WUp2C6HCM,1072
9
+ agentum_cloud_sdk-0.1.62.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Agentum Systems
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.