mlspace-plugin 0.2.1__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.
- mlspace_mcp/__init__.py +3 -0
- mlspace_mcp/__main__.py +125 -0
- mlspace_mcp/_status.py +18 -0
- mlspace_mcp/auth.py +109 -0
- mlspace_mcp/bodyspec.py +184 -0
- mlspace_mcp/client.py +170 -0
- mlspace_mcp/client_setup.py +235 -0
- mlspace_mcp/config.py +283 -0
- mlspace_mcp/errors.py +241 -0
- mlspace_mcp/formatting.py +502 -0
- mlspace_mcp/init_cli.py +356 -0
- mlspace_mcp/job_hooks.py +242 -0
- mlspace_mcp/jsonc.py +90 -0
- mlspace_mcp/overview.py +421 -0
- mlspace_mcp/registry.py +632 -0
- mlspace_mcp/resource_refs.py +188 -0
- mlspace_mcp/server.py +190 -0
- mlspace_mcp/setup_ui.py +103 -0
- mlspace_mcp/skills/mlspace-call-deployed-model-predict/SKILL.md +36 -0
- mlspace_mcp/skills/mlspace-deploy-inference-service/SKILL.md +42 -0
- mlspace_mcp/skills/mlspace-diagnose-data-transfer/SKILL.md +41 -0
- mlspace_mcp/skills/mlspace-diagnose-failed-training-job/SKILL.md +64 -0
- mlspace_mcp/skills/mlspace-diagnose-job-launch-rejection/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-diagnose-unhealthy-inference/SKILL.md +40 -0
- mlspace_mcp/skills/mlspace-discover-launchable-resources/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-launch-training-job/SKILL.md +66 -0
- mlspace_mcp/skills/mlspace-list-running-jobs/SKILL.md +43 -0
- mlspace_mcp/skills/mlspace-resume-notebook-and-set-autoshutdown/SKILL.md +44 -0
- mlspace_mcp/skills/mlspace-stage-s3-to-nfs/SKILL.md +45 -0
- mlspace_mcp/skills/mlspace-transfer-compute-node/SKILL.md +63 -0
- mlspace_mcp/spec/body_schemas.json +7506 -0
- mlspace_mcp/spec/openapi.json +28347 -0
- mlspace_mcp/tls.py +46 -0
- mlspace_mcp/tools/__init__.py +1 -0
- mlspace_mcp/tools/allocations.py +92 -0
- mlspace_mcp/tools/async_inference.py +77 -0
- mlspace_mcp/tools/build_image.py +76 -0
- mlspace_mcp/tools/dalle.py +52 -0
- mlspace_mcp/tools/data_transfer.py +260 -0
- mlspace_mcp/tools/docker_registry.py +156 -0
- mlspace_mcp/tools/inference.py +88 -0
- mlspace_mcp/tools/jobs.py +155 -0
- mlspace_mcp/tools/notebooks.py +174 -0
- mlspace_mcp/tools/queues.py +202 -0
- mlspace_mcp/tools/resources.py +86 -0
- mlspace_mcp/tools/tensorboards.py +112 -0
- mlspace_mcp/tools/workspaces.py +92 -0
- mlspace_mcp/workspace_context.py +107 -0
- mlspace_plugin-0.2.1.dist-info/METADATA +427 -0
- mlspace_plugin-0.2.1.dist-info/RECORD +53 -0
- mlspace_plugin-0.2.1.dist-info/WHEEL +4 -0
- mlspace_plugin-0.2.1.dist-info/entry_points.txt +2 -0
- mlspace_plugin-0.2.1.dist-info/licenses/LICENSE +21 -0
mlspace_mcp/errors.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Structured, user-safe errors and HTTP/exception → message mapping.
|
|
2
|
+
|
|
3
|
+
Our own secrets (Authorization / x-api-key / client_secret) are kept out of error
|
|
4
|
+
text by construction: we never put request headers or the request body into
|
|
5
|
+
messages. We DO surface the API's response body (truncated) on 4xx — under the
|
|
6
|
+
assumption the API does not echo submitted credentials back; if that ever proves
|
|
7
|
+
false for some endpoint, add a scrub pass here.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
|
|
16
|
+
_BODY_LIMIT = 600
|
|
17
|
+
|
|
18
|
+
# A generic error page detector. Matches only when the body BEGINS with an HTML
|
|
19
|
+
# document/root/title/heading tag — so it never fires on JSON or plain text that
|
|
20
|
+
# merely mentions "<html>" mid-string. Used solely on 4xx bodies (see below).
|
|
21
|
+
_HTML_START = re.compile(r"\s*<(?:!doctype\s+html|html[\s>]|title>|h1>)", re.IGNORECASE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _body_as_text(resp: httpx.Response) -> str:
|
|
25
|
+
"""Human-readable body text, UNWRAPPING a bare JSON string.
|
|
26
|
+
|
|
27
|
+
Some upstreams return an HTML error page inside a JSON string with a
|
|
28
|
+
``content-type: application/json`` header; then ``resp.json()`` yields a ``str``
|
|
29
|
+
(not a dict), and that str is the HTML. Fail-open: on any decode trouble fall
|
|
30
|
+
back to ``resp.text`` (then to "")."""
|
|
31
|
+
try:
|
|
32
|
+
parsed = resp.json()
|
|
33
|
+
except Exception:
|
|
34
|
+
parsed = None
|
|
35
|
+
if isinstance(parsed, str):
|
|
36
|
+
return parsed
|
|
37
|
+
try:
|
|
38
|
+
return resp.text or ""
|
|
39
|
+
except Exception:
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _is_html_error_page(resp: httpx.Response) -> bool:
|
|
44
|
+
"""True if the response body is a generic HTML error page (by content-type or
|
|
45
|
+
by a leading HTML tag). Fail-open: any unexpected shape returns False, which
|
|
46
|
+
keeps the pre-existing (raw-body) behaviour."""
|
|
47
|
+
try:
|
|
48
|
+
ctype = resp.headers.get("content-type", "").lower()
|
|
49
|
+
if "html" in ctype:
|
|
50
|
+
return True
|
|
51
|
+
return bool(_HTML_START.match(_body_as_text(resp)))
|
|
52
|
+
except Exception:
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _html_note(status: int) -> str:
|
|
57
|
+
"""A concise, honest description of a generic HTML error page for ``status``.
|
|
58
|
+
|
|
59
|
+
Names ONLY the response shape and the indistinguishable states it implies —
|
|
60
|
+
it invents no reason the page did not carry. 400/422 wording is a preventive
|
|
61
|
+
fail-safe (no HTML case has been observed there; every observed one is 404,
|
|
62
|
+
plus text/plain 500 which this does not touch)."""
|
|
63
|
+
note = (
|
|
64
|
+
"the server returned a generic HTML error page instead of a structured JSON "
|
|
65
|
+
"error. It carries no machine-readable reason"
|
|
66
|
+
)
|
|
67
|
+
if status == 404:
|
|
68
|
+
note += (
|
|
69
|
+
" and does not distinguish among the possible causes — wrong resource "
|
|
70
|
+
"name, unsupported route, or a resource that is not ready yet. Treat as: "
|
|
71
|
+
"not found by the upstream service."
|
|
72
|
+
)
|
|
73
|
+
elif 500 <= status < 600:
|
|
74
|
+
note += " — the upstream handler failed before producing one."
|
|
75
|
+
else:
|
|
76
|
+
note += "."
|
|
77
|
+
return note
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class MLSpaceError(Exception):
|
|
81
|
+
"""An error safe to surface to the LLM/user. Tools render it via ``to_text``."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, message: str, *, status: int | None = None, hint: str | None = None):
|
|
84
|
+
self.message = message
|
|
85
|
+
self.status = status
|
|
86
|
+
self.hint = hint
|
|
87
|
+
super().__init__(message)
|
|
88
|
+
|
|
89
|
+
def to_text(self) -> str:
|
|
90
|
+
parts = [f"Error: {self.message}"]
|
|
91
|
+
if self.hint:
|
|
92
|
+
parts.append(self.hint)
|
|
93
|
+
return " ".join(parts)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _safe_body(resp: httpx.Response) -> str:
|
|
97
|
+
try:
|
|
98
|
+
text = resp.text or ""
|
|
99
|
+
except Exception:
|
|
100
|
+
return ""
|
|
101
|
+
text = text.strip().replace("\n", " ")
|
|
102
|
+
if len(text) > _BODY_LIMIT:
|
|
103
|
+
text = text[:_BODY_LIMIT] + "…"
|
|
104
|
+
return text
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _detail(resp: httpx.Response) -> str:
|
|
108
|
+
"""Render an API error body into a concise, actionable message.
|
|
109
|
+
|
|
110
|
+
Handles FastAPI-style ``{"detail": [{"loc": [...], "msg": "..."}]}`` (the
|
|
111
|
+
shape MLSpace returns for validation errors) so the model gets per-field
|
|
112
|
+
reasons it can fix, and falls back to the raw (truncated) body otherwise.
|
|
113
|
+
"""
|
|
114
|
+
try:
|
|
115
|
+
data = resp.json()
|
|
116
|
+
except Exception:
|
|
117
|
+
return _safe_body(resp)
|
|
118
|
+
if isinstance(data, dict):
|
|
119
|
+
detail = data.get("detail", data)
|
|
120
|
+
if isinstance(detail, list):
|
|
121
|
+
parts = []
|
|
122
|
+
for item in detail[:8]:
|
|
123
|
+
if isinstance(item, dict):
|
|
124
|
+
loc = [str(p) for p in item.get("loc", []) if p != "body"]
|
|
125
|
+
msg = item.get("msg") or item.get("type") or ""
|
|
126
|
+
parts.append(f"{'.'.join(loc)}: {msg}".strip(": "))
|
|
127
|
+
else:
|
|
128
|
+
parts.append(str(item))
|
|
129
|
+
if parts:
|
|
130
|
+
return "; ".join(parts)
|
|
131
|
+
if isinstance(detail, str):
|
|
132
|
+
return detail
|
|
133
|
+
# other shapes (e.g. {"reason": ...}), possibly nested under "detail"
|
|
134
|
+
scope = detail if isinstance(detail, dict) else data
|
|
135
|
+
for key in ("reason", "message", "error_message"):
|
|
136
|
+
if isinstance(scope.get(key), str) and scope[key]:
|
|
137
|
+
return scope[key]
|
|
138
|
+
return _safe_body(resp)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def error_from_response(resp: httpx.Response) -> MLSpaceError:
|
|
142
|
+
"""Map an HTTP error response to an actionable MLSpaceError."""
|
|
143
|
+
status = resp.status_code
|
|
144
|
+
body = _safe_body(resp)
|
|
145
|
+
# Detect a generic HTML error page once (fail-open → False keeps raw-body behaviour).
|
|
146
|
+
is_html = _is_html_error_page(resp)
|
|
147
|
+
if status == 400:
|
|
148
|
+
detail = _html_note(400) if is_html else _detail(resp)
|
|
149
|
+
return MLSpaceError(
|
|
150
|
+
f"Bad request (400). {detail}".strip(),
|
|
151
|
+
status=400,
|
|
152
|
+
hint="Check the action's parameters and body against the API schema.",
|
|
153
|
+
)
|
|
154
|
+
if status == 401:
|
|
155
|
+
return MLSpaceError(
|
|
156
|
+
"Authentication failed (401).",
|
|
157
|
+
status=401,
|
|
158
|
+
hint="Check MLSPACE_CLIENT_ID/MLSPACE_CLIENT_SECRET and MLSPACE_API_KEY.",
|
|
159
|
+
)
|
|
160
|
+
if status == 403:
|
|
161
|
+
return MLSpaceError(
|
|
162
|
+
"Permission denied (403).",
|
|
163
|
+
status=403,
|
|
164
|
+
hint="The service account may lack access to this workspace or resource.",
|
|
165
|
+
)
|
|
166
|
+
if status == 404:
|
|
167
|
+
detail = _html_note(404) if is_html else body
|
|
168
|
+
return MLSpaceError(
|
|
169
|
+
f"Not found (404). {detail}".strip(),
|
|
170
|
+
status=404,
|
|
171
|
+
hint="Check the resource name/id and that it exists in this workspace.",
|
|
172
|
+
)
|
|
173
|
+
if status == 409:
|
|
174
|
+
return MLSpaceError(f"Conflict (409). {body}".strip(), status=409)
|
|
175
|
+
if status == 422:
|
|
176
|
+
detail = _html_note(422) if is_html else _detail(resp)
|
|
177
|
+
return MLSpaceError(
|
|
178
|
+
f"Validation error (422). {detail}".strip(),
|
|
179
|
+
status=422,
|
|
180
|
+
hint="The API rejected the request body or parameters.",
|
|
181
|
+
)
|
|
182
|
+
if status == 429:
|
|
183
|
+
retry_after = resp.headers.get("retry-after")
|
|
184
|
+
return MLSpaceError(
|
|
185
|
+
"Rate limited (429).",
|
|
186
|
+
status=429,
|
|
187
|
+
hint=f"Retry after {retry_after}s." if retry_after else "Slow down and retry shortly.",
|
|
188
|
+
)
|
|
189
|
+
if status == 445:
|
|
190
|
+
# 445 is a non-standard code some WAF/security layers emit; the body is a
|
|
191
|
+
# large HTML page we deliberately keep out of the message/hint entirely. We
|
|
192
|
+
# cannot prove from the status alone that it is a WAF, so we phrase it as a
|
|
193
|
+
# possibility and give the one durable, actionable fact: it is deterministic.
|
|
194
|
+
return MLSpaceError(
|
|
195
|
+
"Request blocked before reaching the platform (HTTP 445) — a non-standard "
|
|
196
|
+
"code used by some WAF/security layers.",
|
|
197
|
+
status=445,
|
|
198
|
+
hint="The request tripped a security rule (e.g. shell metacharacters or a "
|
|
199
|
+
"path-traversal-looking value in some field). This is deterministic — change "
|
|
200
|
+
"the offending input; do not retry the request unchanged.",
|
|
201
|
+
)
|
|
202
|
+
if 500 <= status < 600:
|
|
203
|
+
# Not all MLSpace 5xx are transient: some endpoints fail deterministically
|
|
204
|
+
# for a whole class of input (e.g. allocation assignments on cluster-type
|
|
205
|
+
# allocations 500s every single time, verified live). Advising a blind
|
|
206
|
+
# "retry later" there sends an agent into an unbounded retry loop, so the
|
|
207
|
+
# hint deliberately bounds the retry and names the alternative.
|
|
208
|
+
return MLSpaceError(
|
|
209
|
+
f"MLSpace server error ({status}).",
|
|
210
|
+
status=status,
|
|
211
|
+
hint=(
|
|
212
|
+
"Retry ONCE. If it fails again with the same status, treat it as a "
|
|
213
|
+
"deterministic failure of this endpoint for this input — do not keep "
|
|
214
|
+
"retrying; try a different action or report the endpoint as broken."
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
detail = _html_note(status) if is_html else body
|
|
218
|
+
return MLSpaceError(f"Unexpected HTTP {status}. {detail}".strip(), status=status)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def error_from_exception(exc: Exception) -> MLSpaceError:
|
|
222
|
+
"""Map an httpx transport exception to an actionable MLSpaceError."""
|
|
223
|
+
# TimeoutException is the base of Connect/Read/Write/PoolTimeout.
|
|
224
|
+
if isinstance(exc, httpx.TimeoutException):
|
|
225
|
+
return MLSpaceError(
|
|
226
|
+
"Request to the MLSpace API timed out.",
|
|
227
|
+
hint="Retry, or raise MLSPACE_TIMEOUT for slow operations.",
|
|
228
|
+
)
|
|
229
|
+
if isinstance(exc, httpx.ConnectError):
|
|
230
|
+
return MLSpaceError(
|
|
231
|
+
"Could not connect to the MLSpace API.",
|
|
232
|
+
hint="Check network access and MLSPACE_BASE_URL.",
|
|
233
|
+
)
|
|
234
|
+
if isinstance(exc, httpx.RemoteProtocolError):
|
|
235
|
+
return MLSpaceError(
|
|
236
|
+
"Protocol error talking to the MLSpace API.",
|
|
237
|
+
hint="Retry; if it persists the endpoint may be misbehaving.",
|
|
238
|
+
)
|
|
239
|
+
if isinstance(exc, httpx.HTTPError):
|
|
240
|
+
return MLSpaceError(f"HTTP client error ({type(exc).__name__}).")
|
|
241
|
+
return MLSpaceError(f"Unexpected error ({type(exc).__name__}).")
|