stackless-mcp 1.0.0b2__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.
- stackless_mcp/__init__.py +22 -0
- stackless_mcp/auth.py +273 -0
- stackless_mcp/client.py +318 -0
- stackless_mcp/server.py +1158 -0
- stackless_mcp/tool_handlers.py +35 -0
- stackless_mcp-1.0.0b2.dist-info/METADATA +324 -0
- stackless_mcp-1.0.0b2.dist-info/RECORD +10 -0
- stackless_mcp-1.0.0b2.dist-info/WHEEL +4 -0
- stackless_mcp-1.0.0b2.dist-info/entry_points.txt +3 -0
- stackless_mcp-1.0.0b2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Stackless MCP server package."""
|
|
2
|
+
|
|
3
|
+
import tomllib
|
|
4
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
__all__ = ["__version__"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _resolve_version() -> str:
|
|
11
|
+
try:
|
|
12
|
+
return version("stackless-mcp")
|
|
13
|
+
except PackageNotFoundError:
|
|
14
|
+
pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
|
|
15
|
+
try:
|
|
16
|
+
with pyproject.open("rb") as handle:
|
|
17
|
+
return str(tomllib.load(handle)["tool"]["poetry"]["version"])
|
|
18
|
+
except Exception:
|
|
19
|
+
return "0.0.0"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
__version__ = _resolve_version()
|
stackless_mcp/auth.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
import stat
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
import webbrowser
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
from urllib.parse import parse_qs, urlencode, urlparse
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
|
|
18
|
+
from stackless_mcp.client import DEFAULT_TIMEOUT_SECONDS
|
|
19
|
+
|
|
20
|
+
KEYRING_SERVICE = "stackless-mcp"
|
|
21
|
+
FILE_MODE = 0o600
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class StoredCredential:
|
|
26
|
+
base_url: str
|
|
27
|
+
token: str
|
|
28
|
+
expires_at: str | None = None
|
|
29
|
+
user_id: str | None = None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CredentialStore:
|
|
33
|
+
def __init__(self, storage: str = "keyring") -> None:
|
|
34
|
+
if storage not in {"keyring", "file"}:
|
|
35
|
+
raise ValueError("auth storage must be 'keyring' or 'file'")
|
|
36
|
+
self.storage = storage
|
|
37
|
+
|
|
38
|
+
def load(self, base_url: str) -> StoredCredential | None:
|
|
39
|
+
if self.storage == "keyring":
|
|
40
|
+
return self._load_keyring(base_url)
|
|
41
|
+
return self._load_file(base_url)
|
|
42
|
+
|
|
43
|
+
def save(self, credential: StoredCredential) -> None:
|
|
44
|
+
if self.storage == "keyring":
|
|
45
|
+
self._save_keyring(credential)
|
|
46
|
+
else:
|
|
47
|
+
self._save_file(credential)
|
|
48
|
+
|
|
49
|
+
def delete(self, base_url: str) -> None:
|
|
50
|
+
if self.storage == "keyring":
|
|
51
|
+
self._delete_keyring(base_url)
|
|
52
|
+
else:
|
|
53
|
+
self._delete_file(base_url)
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def _username(base_url: str) -> str:
|
|
57
|
+
return base_url.rstrip("/")
|
|
58
|
+
|
|
59
|
+
@staticmethod
|
|
60
|
+
def _payload(credential: StoredCredential) -> str:
|
|
61
|
+
return json.dumps(
|
|
62
|
+
{
|
|
63
|
+
"base_url": credential.base_url.rstrip("/"),
|
|
64
|
+
"token": credential.token,
|
|
65
|
+
"expires_at": credential.expires_at,
|
|
66
|
+
"user_id": credential.user_id,
|
|
67
|
+
},
|
|
68
|
+
sort_keys=True,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _credential_from_payload(payload: str | None) -> StoredCredential | None:
|
|
73
|
+
if not payload:
|
|
74
|
+
return None
|
|
75
|
+
data = json.loads(payload)
|
|
76
|
+
token = str(data.get("token") or "")
|
|
77
|
+
base_url = str(data.get("base_url") or "")
|
|
78
|
+
if not token or not base_url:
|
|
79
|
+
return None
|
|
80
|
+
return StoredCredential(
|
|
81
|
+
base_url=base_url,
|
|
82
|
+
token=token,
|
|
83
|
+
expires_at=data.get("expires_at"),
|
|
84
|
+
user_id=data.get("user_id"),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def _keyring(self):
|
|
88
|
+
try:
|
|
89
|
+
import keyring # type: ignore
|
|
90
|
+
except Exception as exc:
|
|
91
|
+
raise RuntimeError(
|
|
92
|
+
"OS keyring storage is unavailable. Install keyring support or "
|
|
93
|
+
"rerun with --auth-storage file for an explicit file fallback."
|
|
94
|
+
) from exc
|
|
95
|
+
return keyring
|
|
96
|
+
|
|
97
|
+
def _load_keyring(self, base_url: str) -> StoredCredential | None:
|
|
98
|
+
payload = self._keyring().get_password(
|
|
99
|
+
KEYRING_SERVICE, self._username(base_url)
|
|
100
|
+
)
|
|
101
|
+
return self._credential_from_payload(payload)
|
|
102
|
+
|
|
103
|
+
def _save_keyring(self, credential: StoredCredential) -> None:
|
|
104
|
+
self._keyring().set_password(
|
|
105
|
+
KEYRING_SERVICE,
|
|
106
|
+
self._username(credential.base_url),
|
|
107
|
+
self._payload(credential),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def _delete_keyring(self, base_url: str) -> None:
|
|
111
|
+
try:
|
|
112
|
+
self._keyring().delete_password(KEYRING_SERVICE, self._username(base_url))
|
|
113
|
+
except Exception:
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _file_path() -> Path:
|
|
118
|
+
try:
|
|
119
|
+
from platformdirs import user_config_dir # type: ignore
|
|
120
|
+
|
|
121
|
+
root = Path(user_config_dir("stackless-mcp", "Stackless"))
|
|
122
|
+
except Exception:
|
|
123
|
+
root = Path.home() / ".config" / "stackless-mcp"
|
|
124
|
+
return root / "credentials.json"
|
|
125
|
+
|
|
126
|
+
def _load_file(self, base_url: str) -> StoredCredential | None:
|
|
127
|
+
path = self._file_path()
|
|
128
|
+
if not path.is_file():
|
|
129
|
+
return None
|
|
130
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
131
|
+
payload = data.get(self._username(base_url))
|
|
132
|
+
return self._credential_from_payload(json.dumps(payload) if payload else None)
|
|
133
|
+
|
|
134
|
+
def _save_file(self, credential: StoredCredential) -> None:
|
|
135
|
+
path = self._file_path()
|
|
136
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
data: dict[str, Any] = {}
|
|
138
|
+
if path.is_file():
|
|
139
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
140
|
+
data[self._username(credential.base_url)] = json.loads(
|
|
141
|
+
self._payload(credential)
|
|
142
|
+
)
|
|
143
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE)
|
|
144
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
145
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
146
|
+
handle.write("\n")
|
|
147
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
148
|
+
|
|
149
|
+
def _delete_file(self, base_url: str) -> None:
|
|
150
|
+
path = self._file_path()
|
|
151
|
+
if not path.is_file():
|
|
152
|
+
return
|
|
153
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
154
|
+
data.pop(self._username(base_url), None)
|
|
155
|
+
fd = os.open(str(path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, FILE_MODE)
|
|
156
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
157
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
158
|
+
handle.write("\n")
|
|
159
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class _LoginCallbackHandler(BaseHTTPRequestHandler):
|
|
163
|
+
server: "_LoginCallbackServer"
|
|
164
|
+
|
|
165
|
+
def do_GET(self) -> None: # noqa: N802
|
|
166
|
+
parsed = urlparse(self.path)
|
|
167
|
+
if parsed.path != "/callback":
|
|
168
|
+
self.send_error(404)
|
|
169
|
+
return
|
|
170
|
+
params = parse_qs(parsed.query)
|
|
171
|
+
self.server.result = {
|
|
172
|
+
"code": (params.get("code") or [""])[0],
|
|
173
|
+
"state": (params.get("state") or [""])[0],
|
|
174
|
+
"error": (params.get("error") or [""])[0],
|
|
175
|
+
}
|
|
176
|
+
body = b"Stackless MCP login complete. You can close this window."
|
|
177
|
+
self.send_response(200)
|
|
178
|
+
self.send_header("Content-Type", "text/plain; charset=utf-8")
|
|
179
|
+
self.send_header("Content-Length", str(len(body)))
|
|
180
|
+
self.end_headers()
|
|
181
|
+
self.wfile.write(body)
|
|
182
|
+
|
|
183
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
184
|
+
return
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class _LoginCallbackServer(HTTPServer):
|
|
188
|
+
result: dict[str, str] | None = None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _api_url(base_url: str, path: str) -> str:
|
|
192
|
+
return f"{base_url.rstrip('/')}/api/v1{path}"
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def login(
|
|
196
|
+
*,
|
|
197
|
+
base_url: str,
|
|
198
|
+
storage: str = "keyring",
|
|
199
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
200
|
+
open_browser: bool = True,
|
|
201
|
+
client_name: str = "stackless-mcp",
|
|
202
|
+
) -> StoredCredential:
|
|
203
|
+
state = secrets.token_urlsafe(24)
|
|
204
|
+
server = _LoginCallbackServer(("127.0.0.1", 0), _LoginCallbackHandler)
|
|
205
|
+
redirect_uri = f"http://127.0.0.1:{server.server_port}/callback"
|
|
206
|
+
login_url = _api_url(
|
|
207
|
+
base_url,
|
|
208
|
+
"/mcp/auth/login?"
|
|
209
|
+
+ urlencode(
|
|
210
|
+
{
|
|
211
|
+
"state": state,
|
|
212
|
+
"redirect_uri": redirect_uri,
|
|
213
|
+
"client_name": client_name,
|
|
214
|
+
}
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
thread = threading.Thread(target=server.handle_request, daemon=True)
|
|
219
|
+
thread.start()
|
|
220
|
+
if open_browser:
|
|
221
|
+
webbrowser.open(login_url)
|
|
222
|
+
else:
|
|
223
|
+
print(login_url)
|
|
224
|
+
|
|
225
|
+
deadline = time.monotonic() + timeout
|
|
226
|
+
while server.result is None and time.monotonic() < deadline:
|
|
227
|
+
time.sleep(0.1)
|
|
228
|
+
server.server_close()
|
|
229
|
+
if server.result is None:
|
|
230
|
+
raise TimeoutError("Timed out waiting for Stackless MCP login callback.")
|
|
231
|
+
if server.result.get("error"):
|
|
232
|
+
raise RuntimeError(f"Stackless MCP login failed: {server.result['error']}")
|
|
233
|
+
code = server.result.get("code") or ""
|
|
234
|
+
returned_state = server.result.get("state") or ""
|
|
235
|
+
if not code or returned_state != state:
|
|
236
|
+
raise RuntimeError("Stackless MCP login callback was missing code or state.")
|
|
237
|
+
|
|
238
|
+
response = httpx.post(
|
|
239
|
+
_api_url(base_url, "/mcp/auth/exchange"),
|
|
240
|
+
json={"code": code, "state": state, "client_name": client_name},
|
|
241
|
+
timeout=timeout,
|
|
242
|
+
)
|
|
243
|
+
response.raise_for_status()
|
|
244
|
+
payload = response.json()
|
|
245
|
+
credential = StoredCredential(
|
|
246
|
+
base_url=base_url.rstrip("/"),
|
|
247
|
+
token=payload["token"],
|
|
248
|
+
expires_at=payload.get("expires_at"),
|
|
249
|
+
user_id=payload.get("user_id"),
|
|
250
|
+
)
|
|
251
|
+
CredentialStore(storage).save(credential)
|
|
252
|
+
return credential
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def logout(
|
|
256
|
+
*,
|
|
257
|
+
base_url: str,
|
|
258
|
+
storage: str = "keyring",
|
|
259
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
260
|
+
) -> bool:
|
|
261
|
+
store = CredentialStore(storage)
|
|
262
|
+
credential = store.load(base_url)
|
|
263
|
+
if not credential:
|
|
264
|
+
return False
|
|
265
|
+
try:
|
|
266
|
+
httpx.post(
|
|
267
|
+
_api_url(base_url, "/mcp/auth/revoke"),
|
|
268
|
+
headers={"Authorization": f"Bearer {credential.token}"},
|
|
269
|
+
timeout=timeout,
|
|
270
|
+
)
|
|
271
|
+
finally:
|
|
272
|
+
store.delete(base_url)
|
|
273
|
+
return True
|
stackless_mcp/client.py
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from stackless_mcp import __version__
|
|
10
|
+
|
|
11
|
+
DEFAULT_TIMEOUT_SECONDS = 120.0
|
|
12
|
+
API_PREFIX = "/api/v1"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class StacklessApiError(Exception):
|
|
17
|
+
method: str
|
|
18
|
+
path: str
|
|
19
|
+
status_code: int
|
|
20
|
+
detail: Any
|
|
21
|
+
|
|
22
|
+
def __str__(self) -> str:
|
|
23
|
+
detail = self.detail
|
|
24
|
+
if not isinstance(detail, str):
|
|
25
|
+
detail = json.dumps(detail, sort_keys=True)
|
|
26
|
+
return (
|
|
27
|
+
f"Stackless API {self.method} {self.path} failed "
|
|
28
|
+
f"with HTTP {self.status_code}: {detail}"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict[str, Any]:
|
|
32
|
+
return {
|
|
33
|
+
"error": str(self),
|
|
34
|
+
"method": self.method,
|
|
35
|
+
"path": self.path,
|
|
36
|
+
"status_code": self.status_code,
|
|
37
|
+
"detail": self.detail,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class StacklessClient:
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
*,
|
|
45
|
+
base_url: str,
|
|
46
|
+
token: str = "",
|
|
47
|
+
cookie: str = "",
|
|
48
|
+
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
|
49
|
+
transport: httpx.BaseTransport | None = None,
|
|
50
|
+
) -> None:
|
|
51
|
+
if not base_url:
|
|
52
|
+
raise ValueError("Stackless base URL is required")
|
|
53
|
+
if not token and not cookie:
|
|
54
|
+
raise ValueError("Stackless Bearer token or Cookie header is required")
|
|
55
|
+
|
|
56
|
+
self.base_url = base_url.rstrip("/")
|
|
57
|
+
self.token = token.removeprefix("Bearer ").strip() if token else ""
|
|
58
|
+
self.cookie = cookie.strip()
|
|
59
|
+
if self.cookie.lower().startswith("cookie:"):
|
|
60
|
+
self.cookie = self.cookie.split(":", 1)[1].strip()
|
|
61
|
+
self.timeout = timeout
|
|
62
|
+
headers = {
|
|
63
|
+
"Accept": "application/json",
|
|
64
|
+
"X-Stackless-MCP-Client": f"stackless-mcp/{__version__}",
|
|
65
|
+
}
|
|
66
|
+
if self.token:
|
|
67
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
68
|
+
if self.cookie:
|
|
69
|
+
headers["Cookie"] = self.cookie
|
|
70
|
+
|
|
71
|
+
self._client = httpx.Client(
|
|
72
|
+
base_url=self.base_url,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
headers=headers,
|
|
75
|
+
follow_redirects=False,
|
|
76
|
+
transport=transport,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def close(self) -> None:
|
|
80
|
+
self._client.close()
|
|
81
|
+
|
|
82
|
+
def get(self, path: str, *, params: dict[str, Any] | None = None) -> Any:
|
|
83
|
+
return self.request("GET", path, params=params)
|
|
84
|
+
|
|
85
|
+
def post(
|
|
86
|
+
self,
|
|
87
|
+
path: str,
|
|
88
|
+
*,
|
|
89
|
+
json_body: Any | None = None,
|
|
90
|
+
data: dict[str, Any] | None = None,
|
|
91
|
+
files: dict[str, Any] | None = None,
|
|
92
|
+
params: dict[str, Any] | None = None,
|
|
93
|
+
) -> Any:
|
|
94
|
+
return self.request(
|
|
95
|
+
"POST",
|
|
96
|
+
path,
|
|
97
|
+
json_body=json_body,
|
|
98
|
+
data=data,
|
|
99
|
+
files=files,
|
|
100
|
+
params=params,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
def put(self, path: str, *, json_body: Any | None = None) -> Any:
|
|
104
|
+
return self.request("PUT", path, json_body=json_body)
|
|
105
|
+
|
|
106
|
+
def patch(self, path: str, *, json_body: Any | None = None) -> Any:
|
|
107
|
+
return self.request("PATCH", path, json_body=json_body)
|
|
108
|
+
|
|
109
|
+
def delete(self, path: str) -> Any:
|
|
110
|
+
return self.request("DELETE", path)
|
|
111
|
+
|
|
112
|
+
def mcp_tool(
|
|
113
|
+
self,
|
|
114
|
+
tool_name: str,
|
|
115
|
+
*,
|
|
116
|
+
arguments: dict[str, Any] | None = None,
|
|
117
|
+
idempotency_key: str | None = None,
|
|
118
|
+
confirmation: dict[str, Any] | None = None,
|
|
119
|
+
) -> Any:
|
|
120
|
+
body: dict[str, Any] = {"arguments": arguments or {}}
|
|
121
|
+
if idempotency_key is not None:
|
|
122
|
+
body["idempotency_key"] = idempotency_key
|
|
123
|
+
if confirmation is not None:
|
|
124
|
+
body["confirmation"] = confirmation
|
|
125
|
+
return self.post(f"/mcp/tools/{tool_name}", json_body=body)
|
|
126
|
+
|
|
127
|
+
def mcp_operation(self, operation_id: str) -> Any:
|
|
128
|
+
return self.get(f"/mcp/operations/{operation_id}")
|
|
129
|
+
|
|
130
|
+
def capabilities(self) -> Any:
|
|
131
|
+
return self.get("/mcp/capabilities")
|
|
132
|
+
|
|
133
|
+
def request(
|
|
134
|
+
self,
|
|
135
|
+
method: str,
|
|
136
|
+
path: str,
|
|
137
|
+
*,
|
|
138
|
+
json_body: Any | None = None,
|
|
139
|
+
data: dict[str, Any] | None = None,
|
|
140
|
+
files: dict[str, Any] | None = None,
|
|
141
|
+
params: dict[str, Any] | None = None,
|
|
142
|
+
) -> Any:
|
|
143
|
+
api_path = self.api_path(path)
|
|
144
|
+
try:
|
|
145
|
+
response = self._client.request(
|
|
146
|
+
method,
|
|
147
|
+
api_path,
|
|
148
|
+
json=json_body,
|
|
149
|
+
data=data,
|
|
150
|
+
files=files,
|
|
151
|
+
params=params,
|
|
152
|
+
)
|
|
153
|
+
except httpx.RequestError as exc:
|
|
154
|
+
raise StacklessApiError(
|
|
155
|
+
method=method,
|
|
156
|
+
path=api_path,
|
|
157
|
+
status_code=0,
|
|
158
|
+
detail={
|
|
159
|
+
"message": "Could not reach the Stackless API.",
|
|
160
|
+
"error": str(exc),
|
|
161
|
+
},
|
|
162
|
+
) from exc
|
|
163
|
+
return self._parse_response(method, api_path, response)
|
|
164
|
+
|
|
165
|
+
def stream_message(self, conversation_id: str, content: str) -> dict[str, Any]:
|
|
166
|
+
api_path = self.api_path(f"/agents/conversations/{conversation_id}/messages")
|
|
167
|
+
text_parts: list[str] = []
|
|
168
|
+
events: list[dict[str, Any]] = []
|
|
169
|
+
current_event: str | None = None
|
|
170
|
+
data_lines: list[str] = []
|
|
171
|
+
|
|
172
|
+
try:
|
|
173
|
+
with self._client.stream(
|
|
174
|
+
"POST",
|
|
175
|
+
api_path,
|
|
176
|
+
json={"content": content},
|
|
177
|
+
headers={"Accept": "text/event-stream"},
|
|
178
|
+
) as response:
|
|
179
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
180
|
+
if (
|
|
181
|
+
response.status_code >= 300
|
|
182
|
+
or "text/event-stream" not in content_type
|
|
183
|
+
):
|
|
184
|
+
response.read()
|
|
185
|
+
parsed = self._parse_response("POST", api_path, response)
|
|
186
|
+
if isinstance(parsed, dict):
|
|
187
|
+
return parsed
|
|
188
|
+
raise StacklessApiError(
|
|
189
|
+
method="POST",
|
|
190
|
+
path=api_path,
|
|
191
|
+
status_code=response.status_code,
|
|
192
|
+
detail={
|
|
193
|
+
"message": (
|
|
194
|
+
"Stackless agent stream returned a non-SSE response."
|
|
195
|
+
),
|
|
196
|
+
"content_type": content_type,
|
|
197
|
+
"body_preview": self._text_preview(str(parsed)),
|
|
198
|
+
},
|
|
199
|
+
)
|
|
200
|
+
for raw_line in response.iter_lines():
|
|
201
|
+
line = raw_line.strip()
|
|
202
|
+
if not line:
|
|
203
|
+
self._consume_sse_event(
|
|
204
|
+
current_event,
|
|
205
|
+
data_lines,
|
|
206
|
+
events,
|
|
207
|
+
text_parts,
|
|
208
|
+
)
|
|
209
|
+
current_event = None
|
|
210
|
+
data_lines = []
|
|
211
|
+
continue
|
|
212
|
+
if line.startswith("event:"):
|
|
213
|
+
current_event = line.split(":", 1)[1].strip()
|
|
214
|
+
elif line.startswith("data:"):
|
|
215
|
+
data_lines.append(line.split(":", 1)[1].strip())
|
|
216
|
+
except httpx.RequestError as exc:
|
|
217
|
+
raise StacklessApiError(
|
|
218
|
+
method="POST",
|
|
219
|
+
path=api_path,
|
|
220
|
+
status_code=0,
|
|
221
|
+
detail={
|
|
222
|
+
"message": "Could not reach the Stackless API.",
|
|
223
|
+
"error": str(exc),
|
|
224
|
+
},
|
|
225
|
+
) from exc
|
|
226
|
+
|
|
227
|
+
self._consume_sse_event(current_event, data_lines, events, text_parts)
|
|
228
|
+
return {"text": "".join(text_parts), "events": events}
|
|
229
|
+
|
|
230
|
+
def api_path(self, path: str) -> str:
|
|
231
|
+
normalized = path if path.startswith("/") else f"/{path}"
|
|
232
|
+
if normalized.startswith(API_PREFIX + "/") or normalized == API_PREFIX:
|
|
233
|
+
return normalized
|
|
234
|
+
return f"{API_PREFIX}{normalized}"
|
|
235
|
+
|
|
236
|
+
@staticmethod
|
|
237
|
+
def _parse_response(method: str, path: str, response: httpx.Response) -> Any:
|
|
238
|
+
if response.status_code == 204:
|
|
239
|
+
return {"ok": True}
|
|
240
|
+
|
|
241
|
+
if 300 <= response.status_code < 400:
|
|
242
|
+
raise StacklessApiError(
|
|
243
|
+
method=method,
|
|
244
|
+
path=path,
|
|
245
|
+
status_code=response.status_code,
|
|
246
|
+
detail={
|
|
247
|
+
"message": (
|
|
248
|
+
"Stackless redirected the request. This usually means "
|
|
249
|
+
"ALB cookies are missing or expired, or STACKLESS_BASE_URL "
|
|
250
|
+
"does not point at the Stackless app origin."
|
|
251
|
+
),
|
|
252
|
+
"location": response.headers.get("location", ""),
|
|
253
|
+
},
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
body = response.json()
|
|
258
|
+
except ValueError:
|
|
259
|
+
body = response.text
|
|
260
|
+
if StacklessClient._looks_like_html_response(response, body):
|
|
261
|
+
raise StacklessApiError(
|
|
262
|
+
method=method,
|
|
263
|
+
path=path,
|
|
264
|
+
status_code=response.status_code,
|
|
265
|
+
detail={
|
|
266
|
+
"message": (
|
|
267
|
+
"Stackless returned HTML instead of JSON. This usually "
|
|
268
|
+
"means the request reached the ALB/Cognito login flow "
|
|
269
|
+
"instead of the API. Refresh STACKLESS_COOKIE or verify "
|
|
270
|
+
"STACKLESS_BASE_URL."
|
|
271
|
+
),
|
|
272
|
+
"content_type": response.headers.get("content-type", ""),
|
|
273
|
+
"body_preview": StacklessClient._text_preview(body),
|
|
274
|
+
},
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
if response.status_code >= 400:
|
|
278
|
+
detail = body.get("detail", body) if isinstance(body, dict) else body
|
|
279
|
+
raise StacklessApiError(
|
|
280
|
+
method=method,
|
|
281
|
+
path=path,
|
|
282
|
+
status_code=response.status_code,
|
|
283
|
+
detail=detail,
|
|
284
|
+
)
|
|
285
|
+
return body
|
|
286
|
+
|
|
287
|
+
@staticmethod
|
|
288
|
+
def _looks_like_html_response(response: httpx.Response, body: str) -> bool:
|
|
289
|
+
content_type = response.headers.get("content-type", "").lower()
|
|
290
|
+
if "text/html" in content_type:
|
|
291
|
+
return True
|
|
292
|
+
stripped = body.lstrip().lower()
|
|
293
|
+
return stripped.startswith("<!doctype html") or stripped.startswith("<html")
|
|
294
|
+
|
|
295
|
+
@staticmethod
|
|
296
|
+
def _text_preview(text: str, limit: int = 500) -> str:
|
|
297
|
+
compact = " ".join(text.split())
|
|
298
|
+
if len(compact) <= limit:
|
|
299
|
+
return compact
|
|
300
|
+
return compact[:limit] + "..."
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _consume_sse_event(
|
|
304
|
+
event: str | None,
|
|
305
|
+
data_lines: list[str],
|
|
306
|
+
events: list[dict[str, Any]],
|
|
307
|
+
text_parts: list[str],
|
|
308
|
+
) -> None:
|
|
309
|
+
if not event or not data_lines:
|
|
310
|
+
return
|
|
311
|
+
data_text = "\n".join(data_lines)
|
|
312
|
+
try:
|
|
313
|
+
data = json.loads(data_text)
|
|
314
|
+
except ValueError:
|
|
315
|
+
data = {"raw": data_text}
|
|
316
|
+
events.append({"event": event, "data": data})
|
|
317
|
+
if event == "text" and isinstance(data, dict):
|
|
318
|
+
text_parts.append(str(data.get("delta", "")))
|