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 +79 -0
- shadow_os/_specs.py +281 -0
- shadow_os/_transport.py +260 -0
- shadow_os/_version.py +1 -0
- shadow_os/client.py +665 -0
- shadow_os/errors.py +164 -0
- shadow_os/models.py +434 -0
- shadow_os/py.typed +0 -0
- shadow_os-1.0.0.dist-info/METADATA +281 -0
- shadow_os-1.0.0.dist-info/RECORD +11 -0
- shadow_os-1.0.0.dist-info/WHEEL +4 -0
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)
|
shadow_os/_transport.py
ADDED
|
@@ -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"
|