@runpod/mcp-server 3.1.0 → 3.3.0
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.
- package/CHANGELOG.md +80 -0
- package/dist/{chunk-OM6RHPQC.mjs → chunk-ICUS4KGX.mjs} +708 -100
- package/dist/chunk-ICUS4KGX.mjs.map +1 -0
- package/dist/http.cjs +747 -108
- package/dist/http.cjs.map +1 -1
- package/dist/http.mjs +41 -10
- package/dist/http.mjs.map +1 -1
- package/dist/stdio.cjs +1337 -213
- package/dist/stdio.cjs.map +1 -1
- package/dist/stdio.mjs +726 -108
- package/dist/stdio.mjs.map +1 -1
- package/dist/tools.cjs +707 -99
- package/dist/tools.cjs.map +1 -1
- package/dist/tools.d.cts +13 -1
- package/dist/tools.d.ts +13 -1
- package/dist/tools.mjs +1 -1
- package/dist/wizard-FBLOZ6YM.mjs +901 -0
- package/dist/wizard-FBLOZ6YM.mjs.map +1 -0
- package/package.json +2 -2
- package/dist/chunk-OM6RHPQC.mjs.map +0 -1
- package/dist/wizard-37ZJSVB5.mjs +0 -402
- package/dist/wizard-37ZJSVB5.mjs.map +0 -1
package/dist/tools.cjs
CHANGED
|
@@ -38,16 +38,177 @@ module.exports = __toCommonJS(tools_exports);
|
|
|
38
38
|
var import_node_fetch = __toESM(require("node-fetch"), 1);
|
|
39
39
|
var import_node_crypto = require("crypto");
|
|
40
40
|
|
|
41
|
+
// src/_shared/rate-limit.ts
|
|
42
|
+
var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
|
|
43
|
+
var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
|
|
44
|
+
function remainingRequests(raw) {
|
|
45
|
+
const value = raw?.trim();
|
|
46
|
+
return value && /^\d+$/.test(value) ? Number(value) : null;
|
|
47
|
+
}
|
|
48
|
+
function waitSeconds(raw) {
|
|
49
|
+
const value = raw?.trim();
|
|
50
|
+
if (!value || !/^\d+$/.test(value)) return null;
|
|
51
|
+
const seconds = Number(value);
|
|
52
|
+
return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
|
|
53
|
+
}
|
|
54
|
+
var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
|
|
55
|
+
function windowName(raw) {
|
|
56
|
+
const match = WINDOW_NAME_RE.exec(raw.trim());
|
|
57
|
+
return match ? match[1] ?? match[2] : null;
|
|
58
|
+
}
|
|
59
|
+
var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
|
|
60
|
+
var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
|
|
61
|
+
function isWellFormedParam(param) {
|
|
62
|
+
const eq = param.indexOf("=");
|
|
63
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim();
|
|
64
|
+
if (key.includes('"')) return false;
|
|
65
|
+
if (eq === -1) return true;
|
|
66
|
+
const value = param.slice(eq + 1).trim();
|
|
67
|
+
if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
|
|
68
|
+
if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
|
|
69
|
+
return !/[\s"]/.test(value);
|
|
70
|
+
}
|
|
71
|
+
function splitOutsideStrings(input, sep) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
let current = "";
|
|
74
|
+
let inString = false;
|
|
75
|
+
let inDisplay = false;
|
|
76
|
+
let escaped = false;
|
|
77
|
+
let prev = "";
|
|
78
|
+
for (const ch of input) {
|
|
79
|
+
if (escaped) escaped = false;
|
|
80
|
+
else if (inString && !inDisplay && ch === "\\") escaped = true;
|
|
81
|
+
else if (ch === '"') {
|
|
82
|
+
if (!inString) inDisplay = prev === "%";
|
|
83
|
+
inString = !inString;
|
|
84
|
+
} else if (ch === sep && !inString) {
|
|
85
|
+
parts.push(current);
|
|
86
|
+
current = "";
|
|
87
|
+
prev = ch;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
current += ch;
|
|
91
|
+
prev = ch;
|
|
92
|
+
}
|
|
93
|
+
parts.push(current);
|
|
94
|
+
return parts;
|
|
95
|
+
}
|
|
96
|
+
function readParams(params) {
|
|
97
|
+
const values = /* @__PURE__ */ new Map();
|
|
98
|
+
let unreadable = false;
|
|
99
|
+
let valuelessAt = -1;
|
|
100
|
+
for (const [index, param] of params.entries()) {
|
|
101
|
+
const eq = param.indexOf("=");
|
|
102
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
|
|
103
|
+
if (!key) continue;
|
|
104
|
+
if (eq === -1) {
|
|
105
|
+
if (key === "r") unreadable = true;
|
|
106
|
+
if (key === "t") values.delete("t");
|
|
107
|
+
valuelessAt = index;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
|
|
111
|
+
unreadable = true;
|
|
112
|
+
}
|
|
113
|
+
values.set(key, param.slice(eq + 1));
|
|
114
|
+
}
|
|
115
|
+
return { values, unreadable };
|
|
116
|
+
}
|
|
117
|
+
function parseExhaustedWindows(header) {
|
|
118
|
+
if (!header) return { windows: [], dropped: false };
|
|
119
|
+
const windows = [];
|
|
120
|
+
let dropped = false;
|
|
121
|
+
for (const item of splitOutsideStrings(header, ",")) {
|
|
122
|
+
if (item.trim() === "") continue;
|
|
123
|
+
const [rawName, ...params] = splitOutsideStrings(item, ";");
|
|
124
|
+
if (!params.every(isWellFormedParam)) {
|
|
125
|
+
dropped = true;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
const { values, unreadable } = readParams(params);
|
|
129
|
+
if (unreadable) {
|
|
130
|
+
dropped = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const remaining = remainingRequests(values.get("r"));
|
|
134
|
+
if (remaining === null) {
|
|
135
|
+
dropped = true;
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (remaining !== 0) continue;
|
|
139
|
+
windows.push({
|
|
140
|
+
window: windowName(rawName),
|
|
141
|
+
resetS: waitSeconds(values.get("t"))
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return { windows, dropped };
|
|
145
|
+
}
|
|
146
|
+
function humanWait(seconds) {
|
|
147
|
+
if (seconds < 120) return `~${seconds}s`;
|
|
148
|
+
if (seconds < 3600)
|
|
149
|
+
return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
|
|
150
|
+
if (seconds < 2 * 86400) {
|
|
151
|
+
const hours = Math.ceil(seconds / 3600);
|
|
152
|
+
return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
|
|
153
|
+
}
|
|
154
|
+
return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
|
|
155
|
+
}
|
|
156
|
+
function rateLimitHint(rateLimitHeader, retryAfterHeader) {
|
|
157
|
+
const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
|
|
158
|
+
windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
|
|
159
|
+
const longestWindow = windows[0];
|
|
160
|
+
const waitCandidates = [
|
|
161
|
+
waitSeconds(retryAfterHeader),
|
|
162
|
+
longestWindow?.resetS
|
|
163
|
+
].filter((n) => typeof n === "number" && n > 0);
|
|
164
|
+
const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
|
|
165
|
+
const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
|
|
166
|
+
if (!longestWindow && waitS === null) {
|
|
167
|
+
if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
|
|
168
|
+
}
|
|
169
|
+
const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
|
|
170
|
+
if (waitS === null) {
|
|
171
|
+
const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
|
|
172
|
+
return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
|
|
173
|
+
}
|
|
174
|
+
const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
|
|
175
|
+
const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
|
|
176
|
+
return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
41
179
|
// src/_shared/http.ts
|
|
180
|
+
var EXPIRED_CREDENTIAL_HINT = "the Runpod API rejected the credential \u2014 the API key may be expired or revoked; re-authenticate or update RUNPOD_API_KEY";
|
|
42
181
|
var HttpError = class extends Error {
|
|
43
|
-
constructor(prefix, status, body) {
|
|
44
|
-
const
|
|
45
|
-
super(`${prefix}: ${status} - ${
|
|
182
|
+
constructor(prefix, status, body, hint) {
|
|
183
|
+
const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
|
|
184
|
+
super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
|
|
46
185
|
this.name = "HttpError";
|
|
47
186
|
this.status = status;
|
|
48
187
|
this.body = body;
|
|
49
188
|
}
|
|
50
189
|
};
|
|
190
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
191
|
+
var MIN_REQUEST_TIMEOUT_MS = 1e3;
|
|
192
|
+
var RequestTimeoutError = class extends Error {
|
|
193
|
+
constructor(prefix, timeoutMs, method) {
|
|
194
|
+
const write = method !== "GET" && method !== "HEAD";
|
|
195
|
+
super(
|
|
196
|
+
`${prefix}: no response after ${timeoutMs}ms for ${method} (the API may be overloaded, or still be working on it). ${write ? "This request may have SUCCEEDED upstream \u2014 do not retry blindly. Check with the matching list-/get- tool first and retry only if it did not take effect." : "This was a read and changed nothing, so retrying is safe."}`
|
|
197
|
+
);
|
|
198
|
+
this.name = "RequestTimeoutError";
|
|
199
|
+
this.timeoutMs = timeoutMs;
|
|
200
|
+
this.method = method;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
|
|
204
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
205
|
+
try {
|
|
206
|
+
return await run(signal);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error instanceof HttpError || !signal.aborted) throw error;
|
|
209
|
+
throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
51
212
|
function isJsonContentType(contentType) {
|
|
52
213
|
if (!contentType) return false;
|
|
53
214
|
const ct = contentType.toLowerCase();
|
|
@@ -63,24 +224,49 @@ function buildRequestHeaders(apiKey, tracking) {
|
|
|
63
224
|
...tracking()
|
|
64
225
|
};
|
|
65
226
|
}
|
|
227
|
+
function resolveCeiling(ceiling) {
|
|
228
|
+
if (ceiling === void 0) return Infinity;
|
|
229
|
+
if (typeof ceiling !== "function") return ceiling;
|
|
230
|
+
return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
|
|
231
|
+
}
|
|
232
|
+
function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
233
|
+
return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
|
|
234
|
+
}
|
|
66
235
|
function createHttpClient(deps) {
|
|
67
|
-
|
|
68
|
-
|
|
236
|
+
const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
237
|
+
return async function request(url, method = "GET", body, options) {
|
|
238
|
+
const timeoutMs = clampTimeout(
|
|
239
|
+
options?.timeoutMs,
|
|
240
|
+
deps.maxTimeoutMs,
|
|
241
|
+
defaultTimeoutMs
|
|
242
|
+
);
|
|
243
|
+
return withRequestTimeout(
|
|
244
|
+
deps.errorPrefix,
|
|
245
|
+
timeoutMs,
|
|
69
246
|
method,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
response.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
247
|
+
async (signal) => {
|
|
248
|
+
const init = {
|
|
249
|
+
method,
|
|
250
|
+
headers: buildRequestHeaders(deps.apiKey, deps.tracking),
|
|
251
|
+
signal
|
|
252
|
+
};
|
|
253
|
+
if (body && methodSendsBody(method)) {
|
|
254
|
+
init.body = JSON.stringify(body);
|
|
255
|
+
}
|
|
256
|
+
const response = await deps.fetch(url, init);
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
const hint = response.status === 429 ? rateLimitHint(
|
|
259
|
+
response.headers.get("ratelimit"),
|
|
260
|
+
response.headers.get("retry-after")
|
|
261
|
+
) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
|
|
262
|
+
const text = await response.text().catch(
|
|
263
|
+
() => "<body unavailable: the connection closed while reading it>"
|
|
264
|
+
);
|
|
265
|
+
throw new HttpError(deps.errorPrefix, response.status, text, hint);
|
|
266
|
+
}
|
|
267
|
+
return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
|
|
268
|
+
}
|
|
269
|
+
);
|
|
84
270
|
};
|
|
85
271
|
}
|
|
86
272
|
|
|
@@ -136,7 +322,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
|
|
|
136
322
|
throw new HttpError(
|
|
137
323
|
"Runpod API Error",
|
|
138
324
|
res.status,
|
|
139
|
-
await res.text().catch(() => "")
|
|
325
|
+
await res.text().catch(() => ""),
|
|
326
|
+
// This throw bypasses createHttpClient, so the status hints are built
|
|
327
|
+
// here too or these two tools alone return a bare 429/401. The 401
|
|
328
|
+
// hint applies: this request always carries Authorization (see the
|
|
329
|
+
// headers bound in runtime.ts streamSse).
|
|
330
|
+
res.status === 429 ? rateLimitHint(
|
|
331
|
+
res.headers.get("ratelimit"),
|
|
332
|
+
res.headers.get("retry-after")
|
|
333
|
+
) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
140
334
|
);
|
|
141
335
|
}
|
|
142
336
|
for await (const chunk of res.body ?? []) {
|
|
@@ -225,7 +419,7 @@ function containerConfigToV2(p) {
|
|
|
225
419
|
function gpuConfigToV2(gpuTypeIds, gpuCount) {
|
|
226
420
|
const id = gpuTypeIds?.[0];
|
|
227
421
|
if (id === void 0) return void 0;
|
|
228
|
-
return
|
|
422
|
+
return { id, count: gpuCount ?? 1 };
|
|
229
423
|
}
|
|
230
424
|
function mapPodCreateToV2(params) {
|
|
231
425
|
return compact({
|
|
@@ -352,6 +546,42 @@ function podBodyFromTemplate(template) {
|
|
|
352
546
|
}
|
|
353
547
|
return out;
|
|
354
548
|
}
|
|
549
|
+
var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
|
|
550
|
+
var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
|
|
551
|
+
function normalizeSshPublicKey(input) {
|
|
552
|
+
if (PRIVATE_KEY_MARKER.test(input))
|
|
553
|
+
return {
|
|
554
|
+
ok: false,
|
|
555
|
+
error: 'sshPublicKey looks like a PRIVATE key. Pass the PUBLIC key instead \u2014 the contents of your ~/.ssh/<key>.pub file (e.g. "ssh-ed25519 AAAA\u2026").'
|
|
556
|
+
};
|
|
557
|
+
const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
558
|
+
if (lines.length === 0)
|
|
559
|
+
return {
|
|
560
|
+
ok: false,
|
|
561
|
+
error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
|
|
562
|
+
};
|
|
563
|
+
if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
|
|
564
|
+
return {
|
|
565
|
+
ok: false,
|
|
566
|
+
error: 'sshPublicKey is not an SSH public key. Expected the CONTENTS of your ~/.ssh/<key>.pub file \u2014 a line like "ssh-ed25519 AAAAC3Nza\u2026 you@host" (ssh-*, ecdsa-*, or sk-* followed by the base64 key) \u2014 not a file path, a SHA256 fingerprint, or a bare base64 blob. Newline-separate multiple keys.'
|
|
567
|
+
};
|
|
568
|
+
return { ok: true, key: lines.join("\n") };
|
|
569
|
+
}
|
|
570
|
+
function applySshPublicKey(body, sshPublicKey) {
|
|
571
|
+
const ports = Array.isArray(body.ports) ? body.ports : [];
|
|
572
|
+
const hasSshPort = ports.some((p) => /^\s*22\/tcp\s*$/i.test(String(p)));
|
|
573
|
+
const env = { ...body.env ?? {} };
|
|
574
|
+
const key = sshPublicKey.trim();
|
|
575
|
+
if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
|
|
576
|
+
else if (!env.PUBLIC_KEY.includes(key))
|
|
577
|
+
env.PUBLIC_KEY = `${env.PUBLIC_KEY}
|
|
578
|
+
${key}`;
|
|
579
|
+
return {
|
|
580
|
+
...body,
|
|
581
|
+
ports: hasSshPort ? ports : [...ports, "22/tcp"],
|
|
582
|
+
env
|
|
583
|
+
};
|
|
584
|
+
}
|
|
355
585
|
function mapTemplateUpdateToV2(params) {
|
|
356
586
|
return {
|
|
357
587
|
...containerConfigToV2(params),
|
|
@@ -395,7 +625,7 @@ function restV1Base(env) {
|
|
|
395
625
|
return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
|
|
396
626
|
}
|
|
397
627
|
function restV2Base(env) {
|
|
398
|
-
return env.RUNPOD_REST_V2_API_URL ?? "https://
|
|
628
|
+
return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
|
|
399
629
|
}
|
|
400
630
|
function serverlessBase(env) {
|
|
401
631
|
return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
|
|
@@ -558,7 +788,7 @@ function resolveBackend(opts) {
|
|
|
558
788
|
}
|
|
559
789
|
|
|
560
790
|
// src/tools/runtime.ts
|
|
561
|
-
var MCP_SERVER_VERSION = true ? "3.
|
|
791
|
+
var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
|
|
562
792
|
var SESSION_ID = (0, import_node_crypto.randomUUID)();
|
|
563
793
|
function trackingHeaders(server, ctx) {
|
|
564
794
|
const info = server.server.getClientVersion();
|
|
@@ -573,6 +803,11 @@ function trackingHeaders(server, ctx) {
|
|
|
573
803
|
sessionId: SESSION_ID
|
|
574
804
|
});
|
|
575
805
|
}
|
|
806
|
+
var HTTP_TRANSPORT_BUDGET_MS = 5e4;
|
|
807
|
+
function invocationBudget(totalMs) {
|
|
808
|
+
const startedAt = Date.now();
|
|
809
|
+
return () => totalMs - (Date.now() - startedAt);
|
|
810
|
+
}
|
|
576
811
|
var defaultFetch = import_node_fetch.default;
|
|
577
812
|
async function withApiErrorLog(label, call) {
|
|
578
813
|
try {
|
|
@@ -596,6 +831,23 @@ var DESTRUCTIVE = {
|
|
|
596
831
|
openWorldHint: true
|
|
597
832
|
};
|
|
598
833
|
async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
834
|
+
return withRequestTimeout(
|
|
835
|
+
"Runpod GraphQL Error",
|
|
836
|
+
clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
|
|
837
|
+
// GraphQL is POST on the wire whatever it carries, but the retry advice keys
|
|
838
|
+
// off the method, and telling a caller its list-gpu-types "may have
|
|
839
|
+
// SUCCEEDED upstream — check with the matching list-/get- tool first" points
|
|
840
|
+
// it back at the tool that just failed. The operation keyword is what
|
|
841
|
+
// actually says whether anything could have been written.
|
|
842
|
+
isGraphqlMutation(query) ? "POST" : "GET",
|
|
843
|
+
(signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
function isGraphqlMutation(query) {
|
|
847
|
+
const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
|
|
848
|
+
return /^mutation\b/.test(firstMeaningful ?? "");
|
|
849
|
+
}
|
|
850
|
+
async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
|
|
599
851
|
const response = await fetchImpl(url, {
|
|
600
852
|
method: "POST",
|
|
601
853
|
headers: {
|
|
@@ -606,8 +858,39 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
|
606
858
|
body: JSON.stringify({
|
|
607
859
|
query,
|
|
608
860
|
...options?.variables ? { variables: options.variables } : {}
|
|
609
|
-
})
|
|
861
|
+
}),
|
|
862
|
+
signal
|
|
610
863
|
});
|
|
864
|
+
if (!response.ok) {
|
|
865
|
+
const bodyText = await response.text();
|
|
866
|
+
let gqlErrors;
|
|
867
|
+
try {
|
|
868
|
+
gqlErrors = JSON.parse(bodyText).errors;
|
|
869
|
+
} catch {
|
|
870
|
+
}
|
|
871
|
+
if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
|
|
872
|
+
throw new HttpError(
|
|
873
|
+
"Runpod GraphQL Error",
|
|
874
|
+
response.status,
|
|
875
|
+
gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
|
|
876
|
+
);
|
|
877
|
+
}
|
|
878
|
+
throw new HttpError(
|
|
879
|
+
"Runpod GraphQL Error",
|
|
880
|
+
response.status,
|
|
881
|
+
bodyText,
|
|
882
|
+
response.status === 429 ? rateLimitHint(
|
|
883
|
+
response.headers.get("ratelimit"),
|
|
884
|
+
response.headers.get("retry-after")
|
|
885
|
+
) : (
|
|
886
|
+
// Only when this request actually carried the caller's API key: the
|
|
887
|
+
// public path sends no Authorization header, so a 401 from that
|
|
888
|
+
// host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
|
|
889
|
+
// about the credential — same rationale as observeUnauthorized.
|
|
890
|
+
response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
891
|
+
)
|
|
892
|
+
);
|
|
893
|
+
}
|
|
611
894
|
const result = await response.json();
|
|
612
895
|
if (result.errors && result.errors.length > 0) {
|
|
613
896
|
throw new Error(
|
|
@@ -632,39 +915,53 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
632
915
|
}
|
|
633
916
|
const rawFetch = deps.fetch ?? defaultFetch;
|
|
634
917
|
const httpFetch = observeUnauthorized(rawFetch);
|
|
918
|
+
const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
|
|
919
|
+
const timeouts = {
|
|
920
|
+
defaultTimeoutMs: deps.defaultTimeoutMs,
|
|
921
|
+
maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
|
|
922
|
+
};
|
|
635
923
|
const v1Client = createHttpClient({
|
|
636
924
|
apiKey: ctx.apiKey,
|
|
637
925
|
fetch: httpFetch,
|
|
638
926
|
tracking,
|
|
639
|
-
errorPrefix: "Runpod API Error"
|
|
927
|
+
errorPrefix: "Runpod API Error",
|
|
928
|
+
...timeouts
|
|
640
929
|
});
|
|
641
|
-
const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
|
|
930
|
+
const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
|
|
642
931
|
"Error calling Runpod API:",
|
|
643
|
-
() => v1Client(
|
|
932
|
+
() => v1Client(
|
|
933
|
+
`${restV1Base(process.env)}${endpoint}`,
|
|
934
|
+
method,
|
|
935
|
+
body,
|
|
936
|
+
options
|
|
937
|
+
)
|
|
644
938
|
);
|
|
645
939
|
const serverlessClient = createHttpClient({
|
|
646
940
|
apiKey: ctx.apiKey,
|
|
647
941
|
fetch: httpFetch,
|
|
648
942
|
tracking,
|
|
649
|
-
errorPrefix: "Runpod Serverless API Error"
|
|
943
|
+
errorPrefix: "Runpod Serverless API Error",
|
|
944
|
+
...timeouts
|
|
650
945
|
});
|
|
651
|
-
const serverlessRequest = (endpointId, path, method = "GET", body) => withApiErrorLog(
|
|
946
|
+
const serverlessRequest = (endpointId, path, method = "GET", body, options) => withApiErrorLog(
|
|
652
947
|
"Error calling Runpod Serverless API:",
|
|
653
948
|
() => serverlessClient(
|
|
654
949
|
`${serverlessBase(process.env)}/${endpointId}${path}`,
|
|
655
950
|
method,
|
|
656
|
-
body
|
|
951
|
+
body,
|
|
952
|
+
options
|
|
657
953
|
)
|
|
658
954
|
);
|
|
659
955
|
const restClient = createHttpClient({
|
|
660
956
|
apiKey: ctx.apiKey,
|
|
661
957
|
fetch: httpFetch,
|
|
662
958
|
tracking,
|
|
663
|
-
errorPrefix: "Runpod API Error"
|
|
959
|
+
errorPrefix: "Runpod API Error",
|
|
960
|
+
...timeouts
|
|
664
961
|
});
|
|
665
|
-
const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
|
|
962
|
+
const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
|
|
666
963
|
"Error calling Runpod API:",
|
|
667
|
-
() => restClient(url, method, body)
|
|
964
|
+
() => restClient(url, method, body, options)
|
|
668
965
|
);
|
|
669
966
|
const sseFetch = observeUnauthorized(
|
|
670
967
|
deps.sseFetch ?? import_node_fetch.default
|
|
@@ -697,12 +994,18 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
697
994
|
};
|
|
698
995
|
return {
|
|
699
996
|
jsonReply,
|
|
997
|
+
// No GraphQL caller overrides a deadline, but both are still capped by the
|
|
998
|
+
// shared budget: deploy-hub-repo spends one of each back to back.
|
|
700
999
|
graphql: (query) => graphqlRequest(
|
|
701
1000
|
query,
|
|
702
1001
|
tracking,
|
|
703
1002
|
// rawFetch, not httpFetch: this call carries no credential (see above).
|
|
704
1003
|
rawFetch,
|
|
705
|
-
publicGraphqlBase(process.env)
|
|
1004
|
+
publicGraphqlBase(process.env),
|
|
1005
|
+
{
|
|
1006
|
+
timeoutMs: deps.defaultTimeoutMs,
|
|
1007
|
+
maxTimeoutMs: timeouts.maxTimeoutMs
|
|
1008
|
+
}
|
|
706
1009
|
),
|
|
707
1010
|
graphqlAuthed: (query, variables) => graphqlRequest(
|
|
708
1011
|
query,
|
|
@@ -710,7 +1013,12 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
710
1013
|
httpFetch,
|
|
711
1014
|
// NOT publicGraphqlBase: this call carries the caller's API key.
|
|
712
1015
|
authedGraphqlBase(process.env),
|
|
713
|
-
{
|
|
1016
|
+
{
|
|
1017
|
+
variables,
|
|
1018
|
+
apiKey: ctx.apiKey,
|
|
1019
|
+
timeoutMs: deps.defaultTimeoutMs,
|
|
1020
|
+
maxTimeoutMs: timeouts.maxTimeoutMs
|
|
1021
|
+
}
|
|
714
1022
|
),
|
|
715
1023
|
runpodRequest,
|
|
716
1024
|
serverlessRequest,
|
|
@@ -718,7 +1026,8 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
718
1026
|
backendFor,
|
|
719
1027
|
streamSse,
|
|
720
1028
|
podAction,
|
|
721
|
-
env: process.env
|
|
1029
|
+
env: process.env,
|
|
1030
|
+
transport: ctx.transport
|
|
722
1031
|
};
|
|
723
1032
|
}
|
|
724
1033
|
|
|
@@ -787,6 +1096,7 @@ function capListResult(result, options, extra) {
|
|
|
787
1096
|
}
|
|
788
1097
|
|
|
789
1098
|
// src/tools/catalog.ts
|
|
1099
|
+
var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
|
|
790
1100
|
function registerCatalogTools(server, rt) {
|
|
791
1101
|
const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
|
|
792
1102
|
server.tool(
|
|
@@ -805,6 +1115,9 @@ function registerCatalogTools(server, rt) {
|
|
|
805
1115
|
),
|
|
806
1116
|
includeAvailability: import_zod3.z.boolean().optional().describe(
|
|
807
1117
|
"Request realtime stock and annotate each GPU with an availability summary (HIGH/MEDIUM/LOW/NONE). Default true. Set false to skip the availability lookup \u2014 then out-of-stock GPUs cannot be filtered."
|
|
1118
|
+
),
|
|
1119
|
+
product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
|
|
1120
|
+
"Product context for the availability lookup \u2014 the same GPU can be scarce for Pods and plentiful for Serverless. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false."
|
|
808
1121
|
)
|
|
809
1122
|
},
|
|
810
1123
|
{ title: "List GPU types", ...READ_ONLY },
|
|
@@ -812,8 +1125,9 @@ function registerCatalogTools(server, rt) {
|
|
|
812
1125
|
const backend = backendFor("gpus");
|
|
813
1126
|
if (backend.version === "v2") {
|
|
814
1127
|
const wantAvailability = params.includeAvailability !== false;
|
|
1128
|
+
const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
|
|
815
1129
|
const raw = await callRestUrl(
|
|
816
|
-
`${backend.base}${backend.list}${wantAvailability ?
|
|
1130
|
+
`${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
|
|
817
1131
|
);
|
|
818
1132
|
let gpus = backend.unwrap(raw);
|
|
819
1133
|
gpus = gpus.filter((g) => g.id !== "unknown");
|
|
@@ -986,6 +1300,9 @@ function registerCatalogTools(server, rt) {
|
|
|
986
1300
|
gpuTypeId: import_zod3.z.string().describe("ID of the GPU type to retrieve"),
|
|
987
1301
|
includeAvailability: import_zod3.z.boolean().optional().describe(
|
|
988
1302
|
"Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
|
|
1303
|
+
),
|
|
1304
|
+
product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
|
|
1305
|
+
"Product context for the availability lookup \u2014 stock differs by product. Default POD. Use SERVERLESS when picking a GPU for an endpoint, CLUSTER for Instant Clusters. Ignored when includeAvailability is false."
|
|
989
1306
|
)
|
|
990
1307
|
},
|
|
991
1308
|
{ title: "Get GPU type", ...READ_ONLY },
|
|
@@ -998,7 +1315,8 @@ function registerCatalogTools(server, rt) {
|
|
|
998
1315
|
});
|
|
999
1316
|
}
|
|
1000
1317
|
const path = backend.get(encodeURIComponent(params.gpuTypeId));
|
|
1001
|
-
const
|
|
1318
|
+
const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
|
|
1319
|
+
const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
|
|
1002
1320
|
const result = await callRestUrl(`${backend.base}${path}${query}`);
|
|
1003
1321
|
return jsonReply2(result);
|
|
1004
1322
|
}
|
|
@@ -1024,6 +1342,178 @@ function registerCatalogTools(server, rt) {
|
|
|
1024
1342
|
return jsonReply2(result);
|
|
1025
1343
|
}
|
|
1026
1344
|
);
|
|
1345
|
+
server.tool(
|
|
1346
|
+
"get-capacity",
|
|
1347
|
+
"GPU capacity across host CUDA versions, as a matrix. Use this to choose an endpoint's allowedCudaVersions/minCudaVersion (or diagnose/widen a capacity-starved one) and to distinguish capacity problems from compatibility problems. Default mode is one call returning, per GPU type, overall stock plus AVAILABLE/UNAVAILABLE per host-reported CUDA version. Pass cudaVersions to deep-probe instead: one stock lookup per listed version, returning graded stock (High/Medium/Low/Out) and the lowest on-demand price per version; a probe that fails transiently reports a per-version error instead of failing the call. Nothing is hidden by default \u2014 set includeUnavailable:false to drop GPUs with no stock on any listed version. Credential-free public catalog data; works on both v1 and v2 APIs. Stock is live, so page cursors can shift between calls. Note: the matrix reflects host-reported versions, but endpoint allowedCudaVersions only accepts values from the platform enum \u2014 check create-endpoint/update-endpoint for the accepted list.",
|
|
1348
|
+
{
|
|
1349
|
+
...listPaginationParams,
|
|
1350
|
+
cudaVersions: import_zod3.z.array(import_zod3.z.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
|
|
1351
|
+
'Deep-probe these host CUDA versions (e.g. ["12.8", "13.0"], max 12): one stock lookup per version returns graded stock and price. Omit for the single-call AVAILABLE/UNAVAILABLE matrix across all versions the fleet currently reports.'
|
|
1352
|
+
),
|
|
1353
|
+
includeUnavailable: import_zod3.z.boolean().optional().describe(
|
|
1354
|
+
'Out-of-stock GPUs are included by default (explicit "Out" cells / all-UNAVAILABLE rows, sorted last). Set false to hide GPUs with no stock on any listed version.'
|
|
1355
|
+
),
|
|
1356
|
+
gpuTypeIds: import_zod3.z.array(import_zod3.z.string()).optional().describe(
|
|
1357
|
+
"Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
|
|
1358
|
+
),
|
|
1359
|
+
gpuCount: import_zod3.z.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
|
|
1360
|
+
secureCloudOnly: import_zod3.z.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
|
|
1361
|
+
},
|
|
1362
|
+
{ title: "Get GPU capacity by CUDA version", ...READ_ONLY },
|
|
1363
|
+
async (params) => {
|
|
1364
|
+
const gpuCount = Math.min(
|
|
1365
|
+
8,
|
|
1366
|
+
Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
|
|
1367
|
+
);
|
|
1368
|
+
const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
|
|
1369
|
+
const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
|
|
1370
|
+
const matchesFilter = (gpu) => {
|
|
1371
|
+
if (filterTerms.length === 0) return true;
|
|
1372
|
+
const id = gpu.id.toLowerCase();
|
|
1373
|
+
const name = gpu.displayName.toLowerCase();
|
|
1374
|
+
return filterTerms.some(
|
|
1375
|
+
(term) => id.includes(term) || name.includes(term)
|
|
1376
|
+
);
|
|
1377
|
+
};
|
|
1378
|
+
const stockPriority = {
|
|
1379
|
+
High: 3,
|
|
1380
|
+
Medium: 2,
|
|
1381
|
+
Low: 1
|
|
1382
|
+
};
|
|
1383
|
+
if (!params.cudaVersions?.length) {
|
|
1384
|
+
const data = await graphql(`
|
|
1385
|
+
query {
|
|
1386
|
+
gpuTypes {
|
|
1387
|
+
id
|
|
1388
|
+
displayName
|
|
1389
|
+
memoryInGb
|
|
1390
|
+
secureCloud
|
|
1391
|
+
communityCloud
|
|
1392
|
+
lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
|
|
1393
|
+
stockStatus
|
|
1394
|
+
uninterruptablePrice
|
|
1395
|
+
gpuTypeCudaVersions {
|
|
1396
|
+
cudaVersion
|
|
1397
|
+
availability
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
`);
|
|
1403
|
+
let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
|
|
1404
|
+
const cuda = {};
|
|
1405
|
+
for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
|
|
1406
|
+
cuda[c.cudaVersion] = c.availability;
|
|
1407
|
+
}
|
|
1408
|
+
return {
|
|
1409
|
+
id: gpu.id,
|
|
1410
|
+
displayName: gpu.displayName,
|
|
1411
|
+
memoryGb: gpu.memoryInGb,
|
|
1412
|
+
secureCloud: gpu.secureCloud,
|
|
1413
|
+
communityCloud: gpu.communityCloud,
|
|
1414
|
+
stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
|
|
1415
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
|
|
1416
|
+
cudaVersions: cuda
|
|
1417
|
+
};
|
|
1418
|
+
});
|
|
1419
|
+
const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
|
|
1420
|
+
if (params.includeUnavailable === false)
|
|
1421
|
+
rows2 = rows2.filter((r) => availableCount(r) > 0);
|
|
1422
|
+
rows2.sort((a, b) => {
|
|
1423
|
+
const diff = availableCount(b) - availableCount(a);
|
|
1424
|
+
if (diff !== 0) return diff;
|
|
1425
|
+
const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
|
|
1426
|
+
if (stockDiff !== 0) return stockDiff;
|
|
1427
|
+
return b.memoryGb - a.memoryGb;
|
|
1428
|
+
});
|
|
1429
|
+
return capListResult(rows2, {
|
|
1430
|
+
limit: params.limit,
|
|
1431
|
+
cursor: params.cursor
|
|
1432
|
+
});
|
|
1433
|
+
}
|
|
1434
|
+
const versions = [...new Set(params.cudaVersions)].slice(0, 12);
|
|
1435
|
+
const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
|
|
1436
|
+
if (invalid.length > 0) {
|
|
1437
|
+
return jsonReply2({
|
|
1438
|
+
error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
|
|
1439
|
+
status: 400
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
1442
|
+
const perVersion = await Promise.allSettled(
|
|
1443
|
+
versions.map(
|
|
1444
|
+
(v) => graphql(`
|
|
1445
|
+
query {
|
|
1446
|
+
gpuTypes {
|
|
1447
|
+
id
|
|
1448
|
+
displayName
|
|
1449
|
+
memoryInGb
|
|
1450
|
+
secureCloud
|
|
1451
|
+
communityCloud
|
|
1452
|
+
lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
|
|
1453
|
+
stockStatus
|
|
1454
|
+
uninterruptablePrice
|
|
1455
|
+
}
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
`)
|
|
1459
|
+
)
|
|
1460
|
+
);
|
|
1461
|
+
const probeErrors = {};
|
|
1462
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1463
|
+
versions.forEach((v, i) => {
|
|
1464
|
+
const settled = perVersion[i];
|
|
1465
|
+
if (settled.status === "rejected") {
|
|
1466
|
+
probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
for (const gpu of settled.value.gpuTypes) {
|
|
1470
|
+
if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
|
|
1471
|
+
let row = byId.get(gpu.id);
|
|
1472
|
+
if (!row) {
|
|
1473
|
+
row = {
|
|
1474
|
+
id: gpu.id,
|
|
1475
|
+
displayName: gpu.displayName,
|
|
1476
|
+
memoryGb: gpu.memoryInGb,
|
|
1477
|
+
secureCloud: gpu.secureCloud,
|
|
1478
|
+
communityCloud: gpu.communityCloud,
|
|
1479
|
+
cudaVersions: {}
|
|
1480
|
+
};
|
|
1481
|
+
byId.set(gpu.id, row);
|
|
1482
|
+
}
|
|
1483
|
+
const stock = gpu.lowestPrice?.stockStatus;
|
|
1484
|
+
row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
|
|
1485
|
+
stock,
|
|
1486
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
|
|
1487
|
+
};
|
|
1488
|
+
}
|
|
1489
|
+
});
|
|
1490
|
+
let rows = [...byId.values()];
|
|
1491
|
+
const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
|
|
1492
|
+
if (params.includeUnavailable === false)
|
|
1493
|
+
rows = rows.filter((r) => inStockCount(r) > 0);
|
|
1494
|
+
rows.sort((a, b) => {
|
|
1495
|
+
const diff = inStockCount(b) - inStockCount(a);
|
|
1496
|
+
if (diff !== 0) return diff;
|
|
1497
|
+
const best = (r) => Math.max(
|
|
1498
|
+
0,
|
|
1499
|
+
...Object.values(r.cudaVersions).map(
|
|
1500
|
+
(c) => stockPriority[c.stock] || 0
|
|
1501
|
+
)
|
|
1502
|
+
);
|
|
1503
|
+
const bestDiff = best(b) - best(a);
|
|
1504
|
+
if (bestDiff !== 0) return bestDiff;
|
|
1505
|
+
return b.memoryGb - a.memoryGb;
|
|
1506
|
+
});
|
|
1507
|
+
return capListResult(
|
|
1508
|
+
rows,
|
|
1509
|
+
{ limit: params.limit, cursor: params.cursor },
|
|
1510
|
+
{
|
|
1511
|
+
probedCudaVersions: versions,
|
|
1512
|
+
...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
|
|
1513
|
+
}
|
|
1514
|
+
);
|
|
1515
|
+
}
|
|
1516
|
+
);
|
|
1027
1517
|
server.tool(
|
|
1028
1518
|
"get-data-center",
|
|
1029
1519
|
"Get details for a single data center by id. v2-only \u2014 returns a 501 notice on the v1 API (use list-data-centers there).",
|
|
@@ -1706,9 +2196,11 @@ function registerPodTools(server, rt) {
|
|
|
1706
2196
|
);
|
|
1707
2197
|
server.tool(
|
|
1708
2198
|
"create-pod",
|
|
1709
|
-
'Create a new GPU/CPU pod on Runpod. Pass gpuTypeIds for a GPU pod, or computeType:"CPU" for a CPU pod (CPU pods are served by the v1 API for now). Pass either imageName or templateId (templateId deploys from an existing template \u2014 its name, image, start command, ports, env, disk, volume, and registry credential are used as defaults, and any field you also pass explicitly replaces the template value for that field, e.g. passing env replaces the whole env set rather than merging; pass containerRegistryAuthId to override or supply a private-image credential, or an empty string to deploy with none). If the user specifies neither an image nor a template, recommend the "Runpod Pytorch 2.8.0" image (runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404) as the default \u2014 it has the most up-to-date CUDA and PyTorch versions.',
|
|
2199
|
+
'Create a new GPU/CPU pod on Runpod. Pass gpuTypeIds for a GPU pod, or computeType:"CPU" for a CPU pod (CPU pods are served by the v1 API for now). Pass either imageName or templateId (templateId deploys from an existing template \u2014 its name, image, start command, ports, env, disk, volume, and registry credential are used as defaults, and any field you also pass explicitly replaces the template value for that field, e.g. passing env replaces the whole env set rather than merging; pass containerRegistryAuthId to override or supply a private-image credential, or an empty string to deploy with none). For full SSH access (including SCP/SFTP/rsync), pass sshPublicKey \u2014 it sets the PUBLIC_KEY env var and exposes 22/tcp, which official Runpod images turn into a running sshd with the key authorized. If the user specifies neither an image nor a template, recommend the "Runpod Pytorch 2.8.0" image (runpod/pytorch:1.0.2-cu1281-torch280-ubuntu2404) as the default \u2014 it has the most up-to-date CUDA and PyTorch versions.',
|
|
1710
2200
|
{
|
|
1711
|
-
name: import_zod7.z.string().optional().describe(
|
|
2201
|
+
name: import_zod7.z.string().optional().describe(
|
|
2202
|
+
"Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
|
|
2203
|
+
),
|
|
1712
2204
|
imageName: import_zod7.z.string().optional().describe("Docker image to use. Optional when templateId is provided."),
|
|
1713
2205
|
templateId: import_zod7.z.string().optional().describe(
|
|
1714
2206
|
"Deploy from an existing template (see list-templates / get-template). The template supplies name, image, start command, ports, env, disk, volume, and registry credential as defaults; any field you also pass replaces the template value for that field (whole-field, not a merge). Override the registry credential with containerRegistryAuthId. Requires the v2 REST API. Still pass gpuTypeIds or computeType to choose the compute \u2014 a template does not pin the pod compute."
|
|
@@ -1718,12 +2210,18 @@ function registerPodTools(server, rt) {
|
|
|
1718
2210
|
),
|
|
1719
2211
|
cloudType: import_zod7.z.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
|
|
1720
2212
|
gpuTypeIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of acceptable GPU types"),
|
|
1721
|
-
|
|
2213
|
+
// v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
|
|
2214
|
+
// a negative, or a fractional count reached the wire as-is and came back
|
|
2215
|
+
// as a raw 422; reject it here with a message that names the field.
|
|
2216
|
+
gpuCount: import_zod7.z.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
|
|
1722
2217
|
containerDiskInGb: import_zod7.z.number().optional().describe("Container disk size in GB"),
|
|
1723
2218
|
volumeInGb: import_zod7.z.number().optional().describe("Volume size in GB"),
|
|
1724
2219
|
volumeMountPath: import_zod7.z.string().optional().describe("Path to mount the volume"),
|
|
1725
2220
|
ports: import_zod7.z.array(import_zod7.z.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
|
|
1726
2221
|
env: import_zod7.z.record(import_zod7.z.string()).optional().describe("Environment variables"),
|
|
2222
|
+
sshPublicKey: import_zod7.z.string().optional().describe(
|
|
2223
|
+
'SSH PUBLIC key(s) to authorize on the pod \u2014 the CONTENTS of your ~/.ssh/<key>.pub file, i.e. a literal "ssh-ed25519 AAAAC3Nza\u2026 you@host" line, NOT a path to the file, a fingerprint, or the private key; newline-separate multiple keys. Merges the key into the PUBLIC_KEY env var and ensures 22/tcp is exposed, enabling full SSH (SCP/SFTP/rsync) on any image that honors the PUBLIC_KEY convention \u2014 all runpod/* official images do. Extends template ports/env rather than replacing them.'
|
|
2224
|
+
),
|
|
1727
2225
|
containerRegistryAuthId: import_zod7.z.string().optional().describe(
|
|
1728
2226
|
"Container registry credential ID for pulling a private image (see list-container-registry-auths). When deploying from a templateId, this overrides the credential the template carries; pass an empty string to deploy with NO credential (clears a template\u2019s inherited registry)."
|
|
1729
2227
|
),
|
|
@@ -1732,6 +2230,15 @@ function registerPodTools(server, rt) {
|
|
|
1732
2230
|
{ title: "Create pod", ...WRITE },
|
|
1733
2231
|
async (params) => {
|
|
1734
2232
|
const backend = backendFor("pods");
|
|
2233
|
+
const { sshPublicKey, ...podParams } = params;
|
|
2234
|
+
let sshKey;
|
|
2235
|
+
if (sshPublicKey !== void 0) {
|
|
2236
|
+
const validated = normalizeSshPublicKey(sshPublicKey);
|
|
2237
|
+
if (!validated.ok)
|
|
2238
|
+
return jsonReply2({ error: validated.error, status: 400 });
|
|
2239
|
+
sshKey = validated.key;
|
|
2240
|
+
}
|
|
2241
|
+
const sshNote = "sshPublicKey applied: PUBLIC_KEY env set and 22/tcp exposed. Full SSH (incl. SCP/SFTP/rsync) also requires an image that installs PUBLIC_KEY into authorized_keys and starts sshd (all runpod/* official images do) and a machine with a public IP \u2014 read the pod\u2019s portMappings for port 22 once it is running.";
|
|
1735
2242
|
if (params.templateId && backend.version !== "v2") {
|
|
1736
2243
|
return jsonReply2({
|
|
1737
2244
|
error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
|
|
@@ -1767,6 +2274,12 @@ function registerPodTools(server, rt) {
|
|
|
1767
2274
|
status: 400
|
|
1768
2275
|
});
|
|
1769
2276
|
}
|
|
2277
|
+
if (!wantsCpu && !params.name && !params.templateId) {
|
|
2278
|
+
return jsonReply2({
|
|
2279
|
+
error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
|
|
2280
|
+
status: 400
|
|
2281
|
+
});
|
|
2282
|
+
}
|
|
1770
2283
|
if (wantsCpu) {
|
|
1771
2284
|
if (params.templateId) {
|
|
1772
2285
|
return jsonReply2({
|
|
@@ -1775,13 +2288,15 @@ function registerPodTools(server, rt) {
|
|
|
1775
2288
|
});
|
|
1776
2289
|
}
|
|
1777
2290
|
try {
|
|
2291
|
+
const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
|
|
1778
2292
|
const result = await callRestUrl(
|
|
1779
2293
|
`${restV1Base(env)}/pods`,
|
|
1780
2294
|
"POST",
|
|
1781
|
-
|
|
2295
|
+
cpuBody
|
|
1782
2296
|
);
|
|
1783
2297
|
return jsonReply2({
|
|
1784
2298
|
...result,
|
|
2299
|
+
...sshKey ? { _ssh: sshNote } : {},
|
|
1785
2300
|
_servedBy: "v1",
|
|
1786
2301
|
_note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
|
|
1787
2302
|
});
|
|
@@ -1798,7 +2313,7 @@ function registerPodTools(server, rt) {
|
|
|
1798
2313
|
}
|
|
1799
2314
|
}
|
|
1800
2315
|
}
|
|
1801
|
-
let body = backend.mapCreate(
|
|
2316
|
+
let body = backend.mapCreate(podParams);
|
|
1802
2317
|
if (params.templateId) {
|
|
1803
2318
|
let template;
|
|
1804
2319
|
try {
|
|
@@ -1816,19 +2331,21 @@ function registerPodTools(server, rt) {
|
|
|
1816
2331
|
}
|
|
1817
2332
|
body = { ...podBodyFromTemplate(template), ...body };
|
|
1818
2333
|
}
|
|
2334
|
+
if (sshKey) body = applySshPublicKey(body, sshKey);
|
|
1819
2335
|
try {
|
|
1820
2336
|
const result = await callRestUrl(
|
|
1821
2337
|
`${backend.base}${backend.list}`,
|
|
1822
2338
|
"POST",
|
|
1823
2339
|
body
|
|
1824
2340
|
);
|
|
2341
|
+
const extras = {};
|
|
2342
|
+
if (sshKey) extras._ssh = sshNote;
|
|
1825
2343
|
if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
|
|
1826
|
-
|
|
1827
|
-
...result,
|
|
1828
|
-
_warning: `v2 accepts one GPU type per pod; used "${params.gpuTypeIds[0]}" and ignored ${params.gpuTypeIds.length - 1} other(s): ${params.gpuTypeIds.slice(1).join(", ")}.`
|
|
1829
|
-
});
|
|
2344
|
+
extras._warning = `v2 accepts one GPU type per pod; used "${params.gpuTypeIds[0]}" and ignored ${params.gpuTypeIds.length - 1} other(s): ${params.gpuTypeIds.slice(1).join(", ")}.`;
|
|
1830
2345
|
}
|
|
1831
|
-
return jsonReply2(
|
|
2346
|
+
return jsonReply2(
|
|
2347
|
+
Object.keys(extras).length ? { ...result, ...extras } : result
|
|
2348
|
+
);
|
|
1832
2349
|
} catch (error) {
|
|
1833
2350
|
if (error instanceof HttpError) {
|
|
1834
2351
|
return jsonReply2({ error: error.message, status: error.status });
|
|
@@ -2289,8 +2806,108 @@ var import_zod9 = require("zod");
|
|
|
2289
2806
|
var DIAGNOSIS_TTL_MS = 15e3;
|
|
2290
2807
|
var MAX_DIAGNOSIS_ENTRIES = 500;
|
|
2291
2808
|
var diagnosisCache = /* @__PURE__ */ new Map();
|
|
2809
|
+
var HTTP_LONG_POLL_BUDGET_MS = 45e3;
|
|
2810
|
+
var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
|
|
2811
|
+
var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
|
|
2812
|
+
var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
|
|
2813
|
+
var HTTP_STREAM_POLL_WAIT_MS = 1e3;
|
|
2814
|
+
var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
|
|
2815
|
+
var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
|
|
2816
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
2817
|
+
"COMPLETED",
|
|
2818
|
+
"FAILED",
|
|
2819
|
+
"CANCELLED",
|
|
2820
|
+
"TIMED_OUT"
|
|
2821
|
+
]);
|
|
2822
|
+
function isUnfinishedJobReply(result) {
|
|
2823
|
+
return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
|
|
2824
|
+
}
|
|
2825
|
+
function formatBudget(ms) {
|
|
2826
|
+
return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
|
|
2827
|
+
}
|
|
2828
|
+
var UPSTREAM_HOLD_SLACK_MS = 5e3;
|
|
2829
|
+
var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
|
|
2830
|
+
var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
|
|
2831
|
+
function streamPollTimeoutMs(remainingMs, holdMs) {
|
|
2832
|
+
return Math.max(
|
|
2833
|
+
Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
|
|
2834
|
+
MIN_STREAM_POLL_TIMEOUT_MS
|
|
2835
|
+
);
|
|
2836
|
+
}
|
|
2837
|
+
function sleep(ms) {
|
|
2838
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2839
|
+
}
|
|
2840
|
+
function streamPollPlan(hosted) {
|
|
2841
|
+
return hosted ? {
|
|
2842
|
+
budgetMs: HTTP_LONG_POLL_BUDGET_MS,
|
|
2843
|
+
holdMs: HTTP_STREAM_POLL_WAIT_MS,
|
|
2844
|
+
query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
|
|
2845
|
+
} : {
|
|
2846
|
+
budgetMs: STDIO_STREAM_BUDGET_MS,
|
|
2847
|
+
holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
|
|
2848
|
+
query: ""
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
var RESUME_ADVICE = "Call stream-job again to continue collecting output, get-job-status to check the job without streaming, or stream without a budget by calling the runtime API directly (GET https://api.runpod.ai/v2/{endpointId}/stream/{jobId} with a Bearer API key).";
|
|
2852
|
+
function budgetExhaustedNote(budgetMs, lastError) {
|
|
2853
|
+
return {
|
|
2854
|
+
pollingTimedOut: true,
|
|
2855
|
+
note: `Polling stopped after ${formatBudget(
|
|
2856
|
+
budgetMs
|
|
2857
|
+
)} with the job possibly still running. ${RESUME_ADVICE}`,
|
|
2858
|
+
// Surface the trailing error (if any) instead of discarding it — the last
|
|
2859
|
+
// polls may have been failing (e.g. job expired) even though earlier ones
|
|
2860
|
+
// succeeded. Cleared on success, so this is never a stale error from
|
|
2861
|
+
// minutes of healthy streaming ago.
|
|
2862
|
+
...lastError ? { lastError } : {}
|
|
2863
|
+
};
|
|
2864
|
+
}
|
|
2865
|
+
function errorCapNote(lastError) {
|
|
2866
|
+
return {
|
|
2867
|
+
error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
|
|
2868
|
+
note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
|
|
2869
|
+
};
|
|
2870
|
+
}
|
|
2871
|
+
async function collectJobStream(deps) {
|
|
2872
|
+
const { poll, budgetMs, holdMs } = deps;
|
|
2873
|
+
const startedAt = Date.now();
|
|
2874
|
+
const elapsed = () => Date.now() - startedAt;
|
|
2875
|
+
const chunks = [];
|
|
2876
|
+
let result = {};
|
|
2877
|
+
let consecutiveErrors = 0;
|
|
2878
|
+
let lastError;
|
|
2879
|
+
while (true) {
|
|
2880
|
+
try {
|
|
2881
|
+
const reply = await poll(
|
|
2882
|
+
streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
|
|
2883
|
+
);
|
|
2884
|
+
consecutiveErrors = 0;
|
|
2885
|
+
lastError = void 0;
|
|
2886
|
+
if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
|
|
2887
|
+
result = reply;
|
|
2888
|
+
if (TERMINAL_STATUSES.has(reply.status)) break;
|
|
2889
|
+
} catch (error) {
|
|
2890
|
+
consecutiveErrors++;
|
|
2891
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
2892
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
|
|
2893
|
+
result = { ...result, ...errorCapNote(lastError) };
|
|
2894
|
+
break;
|
|
2895
|
+
}
|
|
2896
|
+
}
|
|
2897
|
+
if (elapsed() > budgetMs) {
|
|
2898
|
+
result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
|
|
2899
|
+
break;
|
|
2900
|
+
}
|
|
2901
|
+
await sleep(STREAM_JOB_POLL_INTERVAL_MS);
|
|
2902
|
+
}
|
|
2903
|
+
return { result, chunks };
|
|
2904
|
+
}
|
|
2292
2905
|
function registerJobTools(server, rt) {
|
|
2293
2906
|
const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
|
|
2907
|
+
const hosted = rt.transport === "http";
|
|
2908
|
+
const streamBudget = formatBudget(
|
|
2909
|
+
hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
|
|
2910
|
+
);
|
|
2294
2911
|
async function diagnoseQueuedJob(endpointId) {
|
|
2295
2912
|
const cached = diagnosisCache.get(endpointId);
|
|
2296
2913
|
if (cached && cached.expiresAt > Date.now()) {
|
|
@@ -2301,7 +2918,10 @@ function registerJobTools(server, rt) {
|
|
|
2301
2918
|
const backend = backendFor("workers");
|
|
2302
2919
|
if (backend.version !== "v2") return null;
|
|
2303
2920
|
const raw = await callRestUrl(
|
|
2304
|
-
`${backend.base}/serverless/${endpointId}/workers
|
|
2921
|
+
`${backend.base}/serverless/${endpointId}/workers`,
|
|
2922
|
+
"GET",
|
|
2923
|
+
void 0,
|
|
2924
|
+
{ timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
|
|
2305
2925
|
);
|
|
2306
2926
|
const summary = raw?.summary;
|
|
2307
2927
|
const workers = raw?.workers ?? [];
|
|
@@ -2392,14 +3012,14 @@ function registerJobTools(server, rt) {
|
|
|
2392
3012
|
);
|
|
2393
3013
|
server.tool(
|
|
2394
3014
|
"runsync-endpoint",
|
|
2395
|
-
|
|
3015
|
+
`Submit a synchronous job to a Serverless endpoint and wait for the result. Best for fast tasks: if the job outlives the wait, the response returns a job ID and a non-terminal status (IN_QUEUE or IN_PROGRESS) to poll with get-job-status. Max payload 20 MB; results expire after 1 minute. ${hosted ? `This server waits up to ${HTTP_LONG_POLL_BUDGET_MS} ms \u2014 it runs behind a 60-second gateway deadline, so that is both the default and the ceiling for the wait parameter. For longer jobs use run-endpoint + get-job-status, or POST https://api.runpod.ai/v2/{endpointId}/runsync?wait=300000 directly with a Bearer API key.` : "The wait parameter extends the server-side wait to up to 5 minutes (300000 ms); it defaults to 90000 (90 seconds)."}`,
|
|
2396
3016
|
{
|
|
2397
3017
|
endpointId: endpointIdSchema.describe(
|
|
2398
3018
|
"ID of the Serverless endpoint to run synchronously"
|
|
2399
3019
|
),
|
|
2400
3020
|
input: inputSchema,
|
|
2401
3021
|
wait: import_zod9.z.number().min(1e3).max(3e5).optional().describe(
|
|
2402
|
-
"How long in milliseconds the server should wait for a result before returning a job ID to poll (1000\u2013300000). Defaults to 90000 (90 seconds)."
|
|
3022
|
+
hosted ? `How long in milliseconds the server should wait for a result before returning a job ID to poll. Accepted range 1000\u2013300000, but this server clamps anything above ${HTTP_LONG_POLL_BUDGET_MS} down to ${HTTP_LONG_POLL_BUDGET_MS}, which is also the effective default.` : "How long in milliseconds the server should wait for a result before returning a job ID to poll (1000\u2013300000). Defaults to 90000 (90 seconds)."
|
|
2403
3023
|
),
|
|
2404
3024
|
webhook: webhookSchema,
|
|
2405
3025
|
policy: policySchema,
|
|
@@ -2408,13 +3028,36 @@ function registerJobTools(server, rt) {
|
|
|
2408
3028
|
{ title: "Run endpoint (sync)", ...WRITE },
|
|
2409
3029
|
async (params) => {
|
|
2410
3030
|
const { endpointId, wait, ...body } = params;
|
|
2411
|
-
const
|
|
3031
|
+
const effectiveWait = hosted ? Math.min(
|
|
3032
|
+
wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
|
|
3033
|
+
HTTP_LONG_POLL_BUDGET_MS
|
|
3034
|
+
) : wait;
|
|
3035
|
+
const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
|
|
3036
|
+
const path = `/runsync${waitQuery}`;
|
|
2412
3037
|
const result = await serverlessRequest(
|
|
2413
3038
|
endpointId,
|
|
2414
3039
|
path,
|
|
2415
3040
|
"POST",
|
|
2416
|
-
body
|
|
3041
|
+
body,
|
|
3042
|
+
{
|
|
3043
|
+
// From the wait actually sent — on http the clamped 45s, not the
|
|
3044
|
+
// 300s the caller may have asked for.
|
|
3045
|
+
timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
|
|
3046
|
+
}
|
|
2417
3047
|
);
|
|
3048
|
+
const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
|
|
3049
|
+
if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
|
|
3050
|
+
return jsonReply2({
|
|
3051
|
+
...result,
|
|
3052
|
+
waitClamped: {
|
|
3053
|
+
requestedMs: requestedWait,
|
|
3054
|
+
effectiveMs: effectiveWait,
|
|
3055
|
+
reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
|
|
3056
|
+
HTTP_LONG_POLL_BUDGET_MS
|
|
3057
|
+
)}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
|
|
3058
|
+
}
|
|
3059
|
+
});
|
|
3060
|
+
}
|
|
2418
3061
|
return jsonReply2(result);
|
|
2419
3062
|
}
|
|
2420
3063
|
);
|
|
@@ -2448,7 +3091,7 @@ function registerJobTools(server, rt) {
|
|
|
2448
3091
|
);
|
|
2449
3092
|
server.tool(
|
|
2450
3093
|
"stream-job",
|
|
2451
|
-
|
|
3094
|
+
`Retrieve streaming output from a Serverless job. The worker must support streaming output. Polls /stream/{jobId} and collects chunks until the status is COMPLETED, FAILED, CANCELLED, or TIMED_OUT, for up to ${streamBudget}${hosted ? " (this server runs behind a 60-second gateway deadline)" : ""}. If the budget expires first, returns the chunks collected so far with pollingTimedOut: true \u2014 call stream-job again to resume where it left off, or get-job-status to check without streaming.${hosted ? " To collect with no budget, poll GET https://api.runpod.ai/v2/{endpointId}/stream/{jobId} yourself with a Bearer API key until the status is terminal \u2014 each request drains only the chunks buffered so far, so a single request is not the whole output." : ""}`,
|
|
2452
3095
|
{
|
|
2453
3096
|
endpointId: endpointIdSchema.describe(
|
|
2454
3097
|
"ID of the Serverless endpoint the job belongs to"
|
|
@@ -2457,51 +3100,16 @@ function registerJobTools(server, rt) {
|
|
|
2457
3100
|
},
|
|
2458
3101
|
{ title: "Stream job", ...READ_ONLY },
|
|
2459
3102
|
async (params) => {
|
|
2460
|
-
const
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
let finalResult = {};
|
|
2471
|
-
let consecutiveErrors = 0;
|
|
2472
|
-
let lastError;
|
|
2473
|
-
const startTime = Date.now();
|
|
2474
|
-
while (true) {
|
|
2475
|
-
try {
|
|
2476
|
-
const result = await serverlessRequest(
|
|
2477
|
-
params.endpointId,
|
|
2478
|
-
`/stream/${params.jobId}`
|
|
2479
|
-
);
|
|
2480
|
-
consecutiveErrors = 0;
|
|
2481
|
-
if (Array.isArray(result.stream)) {
|
|
2482
|
-
allChunks.push(...result.stream);
|
|
2483
|
-
}
|
|
2484
|
-
finalResult = result;
|
|
2485
|
-
if (TERMINAL_STATUSES.has(result.status)) {
|
|
2486
|
-
break;
|
|
2487
|
-
}
|
|
2488
|
-
} catch (error) {
|
|
2489
|
-
consecutiveErrors++;
|
|
2490
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
2491
|
-
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
2492
|
-
finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
|
|
2493
|
-
break;
|
|
2494
|
-
}
|
|
2495
|
-
}
|
|
2496
|
-
if (Date.now() - startTime > MAX_POLL_TIME_MS) {
|
|
2497
|
-
finalResult.pollingTimedOut = true;
|
|
2498
|
-
finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
|
|
2499
|
-
if (lastError) finalResult.lastError = lastError;
|
|
2500
|
-
break;
|
|
2501
|
-
}
|
|
2502
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
2503
|
-
}
|
|
2504
|
-
return jsonReply2({ ...finalResult, stream: allChunks });
|
|
3103
|
+
const plan = streamPollPlan(hosted);
|
|
3104
|
+
const streamPath = `/stream/${params.jobId}${plan.query}`;
|
|
3105
|
+
const { result, chunks } = await collectJobStream({
|
|
3106
|
+
budgetMs: plan.budgetMs,
|
|
3107
|
+
holdMs: plan.holdMs,
|
|
3108
|
+
poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
|
|
3109
|
+
timeoutMs
|
|
3110
|
+
})
|
|
3111
|
+
});
|
|
3112
|
+
return jsonReply2({ ...result, stream: chunks });
|
|
2505
3113
|
}
|
|
2506
3114
|
);
|
|
2507
3115
|
server.tool(
|