shadow-os 1.0.0__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.
shadow_os/__init__.py ADDED
@@ -0,0 +1,79 @@
1
+ """Shadow-OS — the official Python SDK.
2
+
3
+ Build on Shadow-OS from Python: talk to your account assistant, manage documents and semantic search, and
4
+ create + operate configured business agents end to end (knowledge, access links, customers, escalations,
5
+ appointments, analytics).
6
+
7
+ Quickstart::
8
+
9
+ from shadow_os import ShadowOS
10
+
11
+ with ShadowOS() as client: # reads $SHADOW_OS_API_KEY
12
+ print(client.chat("hello"))
13
+
14
+ See https://shadow-os-ai.vercel.app/developers for the full API reference.
15
+ """
16
+ from ._version import __version__
17
+ from .client import (
18
+ AgentClient,
19
+ AgentHandle,
20
+ AsyncAgentClient,
21
+ AsyncAgentHandle,
22
+ AsyncShadowOS,
23
+ ShadowOS,
24
+ )
25
+ from .errors import (
26
+ APIConnectionError,
27
+ APIStatusError,
28
+ APITimeoutError,
29
+ AuthenticationError,
30
+ BadRequestError,
31
+ ConflictError,
32
+ NotFoundError,
33
+ PayloadTooLarge,
34
+ PermissionDeniedError,
35
+ QuotaExceeded,
36
+ RateLimited,
37
+ ServerError,
38
+ ServiceUnavailable,
39
+ ShadowOSError,
40
+ )
41
+ from .models import (
42
+ Agent,
43
+ AgentConfig,
44
+ AgentReply,
45
+ AgentToken,
46
+ Analytics,
47
+ Appointment,
48
+ ChatResponse,
49
+ Conversation,
50
+ CreatedAgent,
51
+ DeliveryStatus,
52
+ Document,
53
+ Escalation,
54
+ KnowledgeItem,
55
+ Member,
56
+ Message,
57
+ PrimaryShareLink,
58
+ SearchHit,
59
+ SearchResults,
60
+ ShareLink,
61
+ Template,
62
+ ToolCatalog,
63
+ Usage,
64
+ )
65
+
66
+ __all__ = [
67
+ "__version__",
68
+ # clients
69
+ "ShadowOS", "AsyncShadowOS", "AgentClient", "AsyncAgentClient", "AgentHandle", "AsyncAgentHandle",
70
+ # models
71
+ "Agent", "AgentConfig", "AgentReply", "AgentToken", "Analytics", "Appointment", "ChatResponse",
72
+ "Conversation", "CreatedAgent", "DeliveryStatus", "Document", "Escalation", "KnowledgeItem",
73
+ "Member", "Message", "PrimaryShareLink", "SearchHit", "SearchResults", "ShareLink", "Template",
74
+ "ToolCatalog", "Usage",
75
+ # errors
76
+ "ShadowOSError", "APIConnectionError", "APITimeoutError", "APIStatusError", "BadRequestError",
77
+ "AuthenticationError", "PermissionDeniedError", "NotFoundError", "ConflictError", "QuotaExceeded",
78
+ "RateLimited", "PayloadTooLarge", "ServerError", "ServiceUnavailable",
79
+ ]
shadow_os/_specs.py ADDED
@@ -0,0 +1,281 @@
1
+ """One definition per API endpoint, shared by the sync and async clients.
2
+
3
+ Every call is described ONCE as a :class:`Call` — method, path, payload and the function that turns the raw
4
+ JSON into a model. The sync and async resource classes are then thin wrappers that execute a ``Call``. That
5
+ is what keeps the two clients from drifting apart: a fix to a path or a parser lands in both, because there
6
+ is only one of each.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Callable, Dict, List, Mapping, Optional, Sequence, Union
14
+
15
+ from . import models as m
16
+
17
+ FileLike = Union[str, "os.PathLike[str]", Path, tuple]
18
+
19
+
20
+ def _compact(d: Dict[str, Any]) -> Dict[str, Any]:
21
+ """Drop keys whose value is None. No endpoint in this API distinguishes an explicit null from an absent
22
+ field, so sending nulls is pure noise on the wire (and in anyone's request logs)."""
23
+ return {k: v for k, v in d.items() if v is not None}
24
+
25
+
26
+ @dataclass
27
+ class Call:
28
+ method: str
29
+ path: str
30
+ json: Any = None
31
+ params: Optional[Dict[str, Any]] = None
32
+ files: Any = None
33
+ data: Any = None
34
+ timeout: Optional[float] = None
35
+ parse: Callable[[Any], Any] = field(default=lambda x: x)
36
+
37
+
38
+ def _as_list(payload: Any, key: str, model) -> List[Any]:
39
+ rows = payload.get(key) if isinstance(payload, Mapping) else payload
40
+ return [model.from_dict(r) for r in (rows or [])]
41
+
42
+
43
+ def _open_upload(file: FileLike, field_name: str = "file") -> Any:
44
+ """Normalise a path / (name, bytes) / (name, bytes, content_type) tuple into httpx's files format."""
45
+ if isinstance(file, tuple):
46
+ return {field_name: file}
47
+ p = Path(file)
48
+ if not p.is_file():
49
+ raise FileNotFoundError(f"No such file: {p}")
50
+ return {field_name: (p.name, p.read_bytes())}
51
+
52
+
53
+ # ══ account assistant ═════════════════════════════════════════════════════════
54
+
55
+ def chat(input: str, session_id: Optional[str] = None, scope: Optional[str] = None) -> Call:
56
+ return Call("POST", "/api/v1/agent",
57
+ json=_compact({"input": input, "session_id": session_id, "scope": scope}),
58
+ parse=m.ChatResponse.from_dict)
59
+
60
+
61
+ def usage() -> Call:
62
+ return Call("GET", "/api/v1/usage", parse=m.Usage.from_dict)
63
+
64
+
65
+ # ══ documents (account-scoped RAG) ════════════════════════════════════════════
66
+
67
+ def upload(file: FileLike, session_id: Optional[str] = None, scope: Optional[str] = None) -> Call:
68
+ data = {k: v for k, v in {"session_id": session_id, "scope": scope}.items() if v is not None}
69
+ return Call("POST", "/api/v1/upload", files=_open_upload(file), data=data or None, parse=lambda d: d)
70
+
71
+
72
+ def list_documents(session_id: Optional[str] = None, scope: Optional[str] = None) -> Call:
73
+ return Call("GET", "/api/v1/documents", params={"session_id": session_id, "scope": scope},
74
+ parse=lambda d: _as_list(d, "documents", m.Document))
75
+
76
+
77
+ def search(query: str, top_k: int = 4, session_id: Optional[str] = None, scope: Optional[str] = None) -> Call:
78
+ return Call("POST", "/api/v1/search",
79
+ json=_compact({"query": query, "top_k": top_k, "session_id": session_id, "scope": scope}),
80
+ parse=m.SearchResults.from_dict)
81
+
82
+
83
+ # ══ agents — lifecycle ════════════════════════════════════════════════════════
84
+
85
+ def create_agent(name: str = "", persona: str = "", template: str = "",
86
+ fields: Optional[Mapping[str, Any]] = None,
87
+ config: Optional[Mapping[str, Any]] = None) -> Call:
88
+ return Call("POST", "/api/v1/agents",
89
+ json={"name": name, "persona": persona, "template": template,
90
+ "fields": dict(fields or {}), "config": dict(config or {})},
91
+ parse=m.CreatedAgent.from_dict)
92
+
93
+
94
+ def list_agents() -> Call:
95
+ return Call("GET", "/api/v1/agents", parse=lambda d: _as_list(d, "agents", m.Agent))
96
+
97
+
98
+ def get_agent(agent_id: str) -> Call:
99
+ return Call("GET", f"/api/agents/{agent_id}",
100
+ parse=lambda d: m.Agent.from_dict(d.get("agent", d) if isinstance(d, Mapping) else d))
101
+
102
+
103
+ def update_agent(agent_id: str, name: Optional[str] = None, persona: Optional[str] = None,
104
+ config: Optional[Mapping[str, Any]] = None) -> Call:
105
+ body: Dict[str, Any] = {}
106
+ if name is not None:
107
+ body["name"] = name
108
+ if persona is not None:
109
+ body["persona"] = persona
110
+ if config is not None:
111
+ body["config"] = dict(config)
112
+ return Call("PATCH", f"/api/v1/agents/{agent_id}", json=body, parse=lambda d: d)
113
+
114
+
115
+ def delete_agent(agent_id: str) -> Call:
116
+ return Call("DELETE", f"/api/agents/{agent_id}", parse=lambda d: d)
117
+
118
+
119
+ def templates() -> Call:
120
+ return Call("GET", "/api/agent/templates", parse=lambda d: _as_list(d, "templates", m.Template))
121
+
122
+
123
+ def tool_catalog() -> Call:
124
+ return Call("GET", "/api/agent/tool-catalog", parse=m.ToolCatalog.from_dict)
125
+
126
+
127
+ # ══ agents — access (tokens & share links) ════════════════════════════════════
128
+
129
+ def create_token(agent_id: str, role: str = "member", label: str = "") -> Call:
130
+ return Call("POST", f"/api/agents/{agent_id}/tokens", json={"role": role, "label": label},
131
+ parse=m.AgentToken.from_dict)
132
+
133
+
134
+ def list_tokens(agent_id: str) -> Call:
135
+ return Call("GET", f"/api/agents/{agent_id}/tokens",
136
+ parse=lambda d: _as_list(d, "tokens", m.AgentToken))
137
+
138
+
139
+ def revoke_token(agent_id: str, token_id: str) -> Call:
140
+ return Call("DELETE", f"/api/agents/{agent_id}/tokens/{token_id}", parse=lambda d: d)
141
+
142
+
143
+ def create_share_link(agent_id: str, role: str = "member", label: str = "") -> Call:
144
+ return Call("POST", f"/api/agents/{agent_id}/share", json={"role": role, "label": label},
145
+ parse=m.ShareLink.from_dict)
146
+
147
+
148
+ def list_share_links(agent_id: str) -> Call:
149
+ return Call("GET", f"/api/agents/{agent_id}/share", parse=lambda d: _as_list(d, "links", m.ShareLink))
150
+
151
+
152
+ def primary_share_link(agent_id: str) -> Call:
153
+ return Call("GET", f"/api/agents/{agent_id}/share-link", parse=m.PrimaryShareLink.from_dict)
154
+
155
+
156
+ def revoke_share_link(agent_id: str, share_id: str) -> Call:
157
+ return Call("DELETE", f"/api/agents/{agent_id}/share/{share_id}", parse=lambda d: d)
158
+
159
+
160
+ # ══ agents — knowledge ════════════════════════════════════════════════════════
161
+
162
+ def add_knowledge_text(agent_id: str, text: str, title: str = "note") -> Call:
163
+ # Server model is _KnowledgeText{title, text} — exactly these two fields.
164
+ return Call("POST", f"/api/agents/{agent_id}/knowledge",
165
+ json={"title": title or "note", "text": text}, parse=lambda d: d)
166
+
167
+
168
+ def add_knowledge_file(agent_id: str, file: FileLike, description: str = "", display_name: str = "") -> Call:
169
+ data = {k: v for k, v in {"description": description, "display_name": display_name}.items() if v}
170
+ return Call("POST", f"/api/agents/{agent_id}/knowledge/file",
171
+ files=_open_upload(file), data=data or None, timeout=300.0, parse=lambda d: d)
172
+
173
+
174
+ def add_knowledge_url(agent_id: str, url: str, title: str = "") -> Call:
175
+ # Server model is _KbUrl{url, title}.
176
+ return Call("POST", f"/api/agents/{agent_id}/knowledge/url",
177
+ json={"url": url, "title": title}, timeout=300.0, parse=lambda d: d)
178
+
179
+
180
+ def list_knowledge(agent_id: str) -> Call:
181
+ return Call("GET", f"/api/agents/{agent_id}/knowledge",
182
+ parse=lambda d: _as_list(d, "files", m.KnowledgeItem))
183
+
184
+
185
+ def delete_knowledge(agent_id: str, file_name: str) -> Call:
186
+ return Call("DELETE", f"/api/agents/{agent_id}/knowledge/{file_name}", parse=lambda d: d)
187
+
188
+
189
+ def rename_knowledge(agent_id: str, file_name: str, new_name: str) -> Call:
190
+ # Server model is _KnowledgeRename{new_name} — a single required field.
191
+ return Call("PATCH", f"/api/agents/{agent_id}/knowledge/{file_name}",
192
+ json={"new_name": new_name}, parse=lambda d: d)
193
+
194
+
195
+ def add_chat_files(agent_id: str, files: Sequence[FileLike]) -> Call:
196
+ """Files the agent may DELIVER to customers, without becoming searchable knowledge."""
197
+ payload = []
198
+ for f in files:
199
+ if isinstance(f, tuple):
200
+ payload.append(("files", f))
201
+ else:
202
+ p = Path(f)
203
+ if not p.is_file():
204
+ raise FileNotFoundError(f"No such file: {p}")
205
+ payload.append(("files", (p.name, p.read_bytes())))
206
+ return Call("POST", f"/api/agents/{agent_id}/chat-file", files=payload, timeout=300.0, parse=lambda d: d)
207
+
208
+
209
+ # ══ agents — customers & operations ═══════════════════════════════════════════
210
+
211
+ def list_members(agent_id: str) -> Call:
212
+ return Call("GET", f"/api/agents/{agent_id}/members", parse=lambda d: _as_list(d, "members", m.Member))
213
+
214
+
215
+ def list_escalations(agent_id: str) -> Call:
216
+ return Call("GET", f"/api/agents/{agent_id}/escalations",
217
+ parse=lambda d: _as_list(d, "escalations", m.Escalation))
218
+
219
+
220
+ def answer_escalation(agent_id: str, escalation_id: str, answer: str) -> Call:
221
+ return Call("POST", f"/api/agents/{agent_id}/escalations/{escalation_id}/answer",
222
+ json={"answer": answer}, parse=lambda d: d)
223
+
224
+
225
+ def list_appointments(agent_id: str) -> Call:
226
+ return Call("GET", f"/api/agents/{agent_id}/appointments",
227
+ parse=lambda d: _as_list(d, "appointments", m.Appointment))
228
+
229
+
230
+ def analytics(agent_id: str) -> Call:
231
+ return Call("GET", f"/api/agents/{agent_id}/analytics", parse=m.Analytics.from_dict)
232
+
233
+
234
+ def delivery_status(agent_id: str) -> Call:
235
+ def _parse(d: Any) -> List[m.DeliveryStatus]:
236
+ statuses = (d or {}).get("statuses") or {}
237
+ return [m.DeliveryStatus.from_dict(k, v) for k, v in statuses.items()]
238
+ return Call("GET", f"/api/agents/{agent_id}/delivery-status", parse=_parse)
239
+
240
+
241
+ def test_agent(agent_id: str, input: str, session_id: str = "test",
242
+ attachments: Optional[Sequence[str]] = None) -> Call:
243
+ return Call("POST", f"/api/agents/{agent_id}/test",
244
+ json={"input": input, "session_id": session_id, "attachments": list(attachments or [])},
245
+ parse=m.AgentReply.from_dict)
246
+
247
+
248
+ def test_history(agent_id: str, session_id: str = "test") -> Call:
249
+ return Call("GET", f"/api/agents/{agent_id}/test-history", params={"session_id": session_id},
250
+ parse=lambda d: _as_list(d, "messages", m.Message))
251
+
252
+
253
+ # ══ talking TO an agent (agent token / share code) ════════════════════════════
254
+
255
+ def agent_info(token: str) -> Call:
256
+ return Call("GET", "/api/agent/info", params={"token": token}, parse=lambda d: d)
257
+
258
+
259
+ def agent_run(token: str, input: str, session_id: str = "default",
260
+ member_key: str = "", member_name: str = "") -> Call:
261
+ return Call("POST", "/api/agent/run",
262
+ json={"token": token, "input": input, "session_id": session_id,
263
+ "member_key": member_key, "member_name": member_name},
264
+ parse=m.AgentReply.from_dict)
265
+
266
+
267
+ def agent_threads(token: str, member_key: str = "") -> Call:
268
+ return Call("GET", "/api/agent/threads", params={"token": token, "member_key": member_key},
269
+ parse=lambda d: _as_list(d, "threads", m.Conversation))
270
+
271
+
272
+ def agent_history(token: str, session_id: str = "main", member_key: str = "") -> Call:
273
+ return Call("GET", "/api/agent/history",
274
+ params={"token": token, "session_id": session_id, "member_key": member_key},
275
+ parse=lambda d: _as_list(d, "messages", m.Message))
276
+
277
+
278
+ def agent_delete_thread(token: str, session_id: str, member_key: str = "") -> Call:
279
+ return Call("POST", "/api/agent/thread/delete",
280
+ json={"token": token, "session_id": session_id, "member_key": member_key},
281
+ parse=lambda d: d)
@@ -0,0 +1,260 @@
1
+ """HTTP transport: one place that knows about retries, timeouts and the two error envelopes.
2
+
3
+ Kept separate from the resource classes so retry/backoff behaviour is defined exactly once and every call in
4
+ the SDK inherits it — including the ones added later.
5
+
6
+ Two things here are shaped by how this particular backend behaves in production:
7
+
8
+ * **Cold starts.** The service can be asleep; the first request then takes tens of seconds or returns 502/503.
9
+ So a connection error, a timeout and 502/503/504 are all treated as *retryable*, with exponential backoff
10
+ and full jitter, and the default timeout is generous rather than snappy.
11
+ * **Two error envelopes.** ``/api/v1/*`` and ``/api/agent/run`` return
12
+ ``{"error": "...", "detail": "...", "request_id": "..."}``; the rest of the API returns FastAPI's plain
13
+ ``{"detail": ...}``. ``_extract_error`` understands both so callers always get the same exception shape.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import random
18
+ import time
19
+ from typing import Any, Mapping, Optional
20
+
21
+ import httpx
22
+
23
+ from . import errors
24
+ from ._version import __version__
25
+
26
+ DEFAULT_BASE_URL = "https://shadow-os-backend.onrender.com"
27
+ DEFAULT_TIMEOUT = 120.0
28
+ DEFAULT_MAX_RETRIES = 3
29
+ _RETRY_STATUSES = frozenset({408, 409, 429, 500, 502, 503, 504})
30
+
31
+
32
+ def _extract_error(response: httpx.Response) -> errors.APIStatusError:
33
+ """Turn a non-2xx response into the right exception, whichever envelope it used."""
34
+ code: Optional[str] = None
35
+ request_id: Optional[str] = response.headers.get("x-request-id")
36
+ message = f"HTTP {response.status_code}"
37
+ body: Any = None
38
+ try:
39
+ body = response.json()
40
+ except Exception:
41
+ text = (response.text or "").strip()
42
+ if text:
43
+ message = text[:500]
44
+
45
+ if isinstance(body, Mapping):
46
+ code = body.get("error") if isinstance(body.get("error"), str) else None
47
+ request_id = body.get("request_id") or request_id
48
+ detail = body.get("detail")
49
+ if isinstance(detail, str) and detail.strip():
50
+ message = detail
51
+ elif isinstance(detail, Mapping):
52
+ message = str(detail.get("message") or detail.get("error") or detail)
53
+ code = code or (detail.get("error") if isinstance(detail.get("error"), str) else None)
54
+ elif isinstance(detail, list) and detail:
55
+ # FastAPI validation errors: surface the first one in a readable way.
56
+ first = detail[0]
57
+ if isinstance(first, Mapping):
58
+ loc = ".".join(str(x) for x in (first.get("loc") or []))
59
+ message = f"{loc}: {first.get('msg')}" if loc else str(first.get("msg"))
60
+ else:
61
+ message = str(first)
62
+
63
+ retry_after: Optional[float] = None
64
+ raw_retry = response.headers.get("retry-after")
65
+ if raw_retry:
66
+ try:
67
+ retry_after = float(raw_retry)
68
+ except ValueError:
69
+ retry_after = None
70
+
71
+ return errors.error_for_status(
72
+ response.status_code,
73
+ message=message,
74
+ code=code,
75
+ request_id=request_id,
76
+ body=body,
77
+ retry_after=retry_after,
78
+ )
79
+
80
+
81
+ def _backoff_delay(attempt: int, retry_after: Optional[float]) -> float:
82
+ """Exponential backoff with full jitter; an explicit Retry-After always wins."""
83
+ if retry_after is not None and retry_after >= 0:
84
+ return min(retry_after, 60.0)
85
+ return random.uniform(0, min(8.0, 0.5 * (2 ** attempt)))
86
+
87
+
88
+ def build_headers(auth_header: Optional[dict], extra: Optional[Mapping[str, str]] = None) -> dict:
89
+ headers = {
90
+ "Accept": "application/json",
91
+ "User-Agent": f"shadow-os-python/{__version__}",
92
+ }
93
+ if auth_header:
94
+ headers.update(auth_header)
95
+ if extra:
96
+ headers.update(extra)
97
+ return headers
98
+
99
+
100
+ class Transport:
101
+ """Synchronous transport wrapping an ``httpx.Client``."""
102
+
103
+ def __init__(
104
+ self,
105
+ base_url: str,
106
+ auth_header: Optional[dict],
107
+ *,
108
+ timeout: float = DEFAULT_TIMEOUT,
109
+ max_retries: int = DEFAULT_MAX_RETRIES,
110
+ client: Optional[httpx.Client] = None,
111
+ default_headers: Optional[Mapping[str, str]] = None,
112
+ ) -> None:
113
+ self.base_url = base_url.rstrip("/")
114
+ self._auth = auth_header
115
+ self.max_retries = max(0, int(max_retries))
116
+ self._owns_client = client is None
117
+ self._client = client or httpx.Client(timeout=timeout, follow_redirects=True)
118
+ self._default_headers = dict(default_headers or {})
119
+
120
+ # -- lifecycle ---------------------------------------------------------
121
+ def close(self) -> None:
122
+ if self._owns_client:
123
+ self._client.close()
124
+
125
+ def __enter__(self) -> "Transport":
126
+ return self
127
+
128
+ def __exit__(self, *exc: Any) -> None:
129
+ self.close()
130
+
131
+ # -- request -----------------------------------------------------------
132
+ def request(
133
+ self,
134
+ method: str,
135
+ path: str,
136
+ *,
137
+ json: Any = None,
138
+ params: Optional[Mapping[str, Any]] = None,
139
+ files: Any = None,
140
+ data: Any = None,
141
+ headers: Optional[Mapping[str, str]] = None,
142
+ timeout: Optional[float] = None,
143
+ ) -> Any:
144
+ url = path if path.startswith("http") else f"{self.base_url}{path}"
145
+ merged = build_headers(self._auth, {**self._default_headers, **(headers or {})})
146
+ # Drop None params so callers can pass optional arguments straight through.
147
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None} or None
148
+
149
+ last_exc: Optional[Exception] = None
150
+ for attempt in range(self.max_retries + 1):
151
+ try:
152
+ response = self._client.request(
153
+ method, url, json=json, params=clean_params, files=files,
154
+ data=data, headers=merged, timeout=timeout,
155
+ )
156
+ except httpx.TimeoutException as exc:
157
+ last_exc = errors.APITimeoutError(f"Request timed out: {exc}")
158
+ except httpx.HTTPError as exc:
159
+ last_exc = errors.APIConnectionError(f"Could not reach Shadow-OS: {exc}")
160
+ else:
161
+ if response.status_code < 300:
162
+ return _decode(response)
163
+ err = _extract_error(response)
164
+ # 409 is retryable only as a transient lock ("still processing"), never for a real conflict.
165
+ if response.status_code in _RETRY_STATUSES and attempt < self.max_retries:
166
+ if response.status_code != 409 or "processing" in err.message.lower():
167
+ time.sleep(_backoff_delay(attempt, getattr(err, "retry_after", None)))
168
+ continue
169
+ raise err
170
+ if attempt < self.max_retries:
171
+ time.sleep(_backoff_delay(attempt, None))
172
+ continue
173
+ break
174
+ # Retries exhausted on a transport-level failure. The fallback is not decoration: `last_exc` is
175
+ # Optional, and `raise None` would turn a network problem into a confusing TypeError.
176
+ raise last_exc or errors.APIConnectionError("Request failed without a response.")
177
+
178
+
179
+ class AsyncTransport:
180
+ """Asynchronous twin of :class:`Transport`. Same behaviour, ``await``-ed."""
181
+
182
+ def __init__(
183
+ self,
184
+ base_url: str,
185
+ auth_header: Optional[dict],
186
+ *,
187
+ timeout: float = DEFAULT_TIMEOUT,
188
+ max_retries: int = DEFAULT_MAX_RETRIES,
189
+ client: Optional[httpx.AsyncClient] = None,
190
+ default_headers: Optional[Mapping[str, str]] = None,
191
+ ) -> None:
192
+ self.base_url = base_url.rstrip("/")
193
+ self._auth = auth_header
194
+ self.max_retries = max(0, int(max_retries))
195
+ self._owns_client = client is None
196
+ self._client = client or httpx.AsyncClient(timeout=timeout, follow_redirects=True)
197
+ self._default_headers = dict(default_headers or {})
198
+
199
+ async def aclose(self) -> None:
200
+ if self._owns_client:
201
+ await self._client.aclose()
202
+
203
+ async def __aenter__(self) -> "AsyncTransport":
204
+ return self
205
+
206
+ async def __aexit__(self, *exc: Any) -> None:
207
+ await self.aclose()
208
+
209
+ async def request(
210
+ self,
211
+ method: str,
212
+ path: str,
213
+ *,
214
+ json: Any = None,
215
+ params: Optional[Mapping[str, Any]] = None,
216
+ files: Any = None,
217
+ data: Any = None,
218
+ headers: Optional[Mapping[str, str]] = None,
219
+ timeout: Optional[float] = None,
220
+ ) -> Any:
221
+ import anyio
222
+
223
+ url = path if path.startswith("http") else f"{self.base_url}{path}"
224
+ merged = build_headers(self._auth, {**self._default_headers, **(headers or {})})
225
+ clean_params = {k: v for k, v in (params or {}).items() if v is not None} or None
226
+
227
+ last_exc: Optional[Exception] = None
228
+ for attempt in range(self.max_retries + 1):
229
+ try:
230
+ response = await self._client.request(
231
+ method, url, json=json, params=clean_params, files=files,
232
+ data=data, headers=merged, timeout=timeout,
233
+ )
234
+ except httpx.TimeoutException as exc:
235
+ last_exc = errors.APITimeoutError(f"Request timed out: {exc}")
236
+ except httpx.HTTPError as exc:
237
+ last_exc = errors.APIConnectionError(f"Could not reach Shadow-OS: {exc}")
238
+ else:
239
+ if response.status_code < 300:
240
+ return _decode(response)
241
+ err = _extract_error(response)
242
+ if response.status_code in _RETRY_STATUSES and attempt < self.max_retries:
243
+ if response.status_code != 409 or "processing" in err.message.lower():
244
+ await anyio.sleep(_backoff_delay(attempt, getattr(err, "retry_after", None)))
245
+ continue
246
+ raise err
247
+ if attempt < self.max_retries:
248
+ await anyio.sleep(_backoff_delay(attempt, None))
249
+ continue
250
+ break
251
+ raise last_exc or errors.APIConnectionError("Request failed without a response.")
252
+
253
+
254
+ def _decode(response: httpx.Response) -> Any:
255
+ if response.status_code == 204 or not response.content:
256
+ return None
257
+ ctype = response.headers.get("content-type", "")
258
+ if "json" in ctype:
259
+ return response.json()
260
+ return response.text
shadow_os/_version.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"