@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/http.cjs
CHANGED
|
@@ -69,7 +69,7 @@ function containerConfigToV2(p) {
|
|
|
69
69
|
function gpuConfigToV2(gpuTypeIds, gpuCount) {
|
|
70
70
|
const id = gpuTypeIds?.[0];
|
|
71
71
|
if (id === void 0) return void 0;
|
|
72
|
-
return
|
|
72
|
+
return { id, count: gpuCount ?? 1 };
|
|
73
73
|
}
|
|
74
74
|
function mapPodCreateToV2(params) {
|
|
75
75
|
return compact({
|
|
@@ -196,6 +196,42 @@ function podBodyFromTemplate(template) {
|
|
|
196
196
|
}
|
|
197
197
|
return out;
|
|
198
198
|
}
|
|
199
|
+
var SSH_PUBLIC_KEY_LINE = /^(ssh-[a-z0-9-]+|ecdsa-[a-z0-9-]+|sk-[a-z0-9@.-]+)\s+\S+/i;
|
|
200
|
+
var PRIVATE_KEY_MARKER = /private[-\s]?key|PuTTY-User-Key-File/i;
|
|
201
|
+
function normalizeSshPublicKey(input) {
|
|
202
|
+
if (PRIVATE_KEY_MARKER.test(input))
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
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").'
|
|
206
|
+
};
|
|
207
|
+
const lines = input.split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
208
|
+
if (lines.length === 0)
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
error: "sshPublicKey is empty. Pass the contents of your ~/.ssh/<key>.pub file, or omit the parameter entirely to create the pod without SSH."
|
|
212
|
+
};
|
|
213
|
+
if (lines.some((line) => !SSH_PUBLIC_KEY_LINE.test(line)))
|
|
214
|
+
return {
|
|
215
|
+
ok: false,
|
|
216
|
+
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.'
|
|
217
|
+
};
|
|
218
|
+
return { ok: true, key: lines.join("\n") };
|
|
219
|
+
}
|
|
220
|
+
function applySshPublicKey(body, sshPublicKey) {
|
|
221
|
+
const ports = Array.isArray(body.ports) ? body.ports : [];
|
|
222
|
+
const hasSshPort = ports.some((p) => /^\s*22\/tcp\s*$/i.test(String(p)));
|
|
223
|
+
const env = { ...body.env ?? {} };
|
|
224
|
+
const key = sshPublicKey.trim();
|
|
225
|
+
if (!env.PUBLIC_KEY) env.PUBLIC_KEY = key;
|
|
226
|
+
else if (!env.PUBLIC_KEY.includes(key))
|
|
227
|
+
env.PUBLIC_KEY = `${env.PUBLIC_KEY}
|
|
228
|
+
${key}`;
|
|
229
|
+
return {
|
|
230
|
+
...body,
|
|
231
|
+
ports: hasSshPort ? ports : [...ports, "22/tcp"],
|
|
232
|
+
env
|
|
233
|
+
};
|
|
234
|
+
}
|
|
199
235
|
function mapTemplateUpdateToV2(params) {
|
|
200
236
|
return {
|
|
201
237
|
...containerConfigToV2(params),
|
|
@@ -239,7 +275,7 @@ function restV1Base(env) {
|
|
|
239
275
|
return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
|
|
240
276
|
}
|
|
241
277
|
function restV2Base(env) {
|
|
242
|
-
return env.RUNPOD_REST_V2_API_URL ?? "https://
|
|
278
|
+
return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
|
|
243
279
|
}
|
|
244
280
|
function serverlessBase(env) {
|
|
245
281
|
return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
|
|
@@ -411,32 +447,203 @@ function resolveBackend(opts) {
|
|
|
411
447
|
|
|
412
448
|
// src/server.ts
|
|
413
449
|
var SERVER_NAME = "Runpod API Server";
|
|
414
|
-
var SERVER_VERSION = true ? "3.
|
|
450
|
+
var SERVER_VERSION = true ? "3.3.0" : "dev";
|
|
451
|
+
var SERVER_INSTRUCTIONS = `These tools cover common Runpod operations, but they are curated projections: the underlying APIs expose more fields and parameters than the tools surface.
|
|
452
|
+
|
|
453
|
+
When you need data or capability beyond these tools, consult the full API contracts directly. The REST v2 API lives at https://api.runpod.io/v2 \u2014 the same host these tools call \u2014 and is machine-readable at https://api.runpod.io/v2/openapi.json (fetch and search it to enumerate every path, parameter, and response schema). REST v1 at https://rest.runpod.io/v1 is deprecated; prefer v2. The REST version these tools are actually configured for is reported in the serverInfo.version string of the initialize response, e.g. "[RUNPOD_REST_VERSION=v2]". The GraphQL API at https://api.runpod.io/graphql may expose data the REST API does not yet cover; its schema is documented at https://graphql-spec.runpod.io/, and introspection is disabled in production, so use that reference rather than __schema queries. Both APIs take Bearer auth with a Runpod API key: use the key this client is already configured with \u2014 do not search the environment or filesystem for a credential.`;
|
|
415
454
|
function createServer(version = SERVER_VERSION) {
|
|
416
|
-
return new import_mcp.McpServer(
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
455
|
+
return new import_mcp.McpServer(
|
|
456
|
+
{
|
|
457
|
+
name: SERVER_NAME,
|
|
458
|
+
version: `${version} [${restVersionSummary(process.env)}]`
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
// Only advertise capabilities the server implements. It registers
|
|
462
|
+
// tools and no resources — advertising `resources` would invite
|
|
463
|
+
// `resources/list` calls that answer -32601.
|
|
464
|
+
capabilities: {
|
|
465
|
+
tools: {}
|
|
466
|
+
},
|
|
467
|
+
instructions: SERVER_INSTRUCTIONS
|
|
422
468
|
}
|
|
423
|
-
|
|
469
|
+
);
|
|
424
470
|
}
|
|
425
471
|
|
|
426
472
|
// src/tools/runtime.ts
|
|
427
473
|
var import_node_fetch = __toESM(require("node-fetch"), 1);
|
|
428
474
|
var import_node_crypto = require("crypto");
|
|
429
475
|
|
|
476
|
+
// src/_shared/rate-limit.ts
|
|
477
|
+
var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
|
|
478
|
+
var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
|
|
479
|
+
function remainingRequests(raw) {
|
|
480
|
+
const value = raw?.trim();
|
|
481
|
+
return value && /^\d+$/.test(value) ? Number(value) : null;
|
|
482
|
+
}
|
|
483
|
+
function waitSeconds(raw) {
|
|
484
|
+
const value = raw?.trim();
|
|
485
|
+
if (!value || !/^\d+$/.test(value)) return null;
|
|
486
|
+
const seconds = Number(value);
|
|
487
|
+
return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
|
|
488
|
+
}
|
|
489
|
+
var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
|
|
490
|
+
function windowName(raw) {
|
|
491
|
+
const match = WINDOW_NAME_RE.exec(raw.trim());
|
|
492
|
+
return match ? match[1] ?? match[2] : null;
|
|
493
|
+
}
|
|
494
|
+
var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
|
|
495
|
+
var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
|
|
496
|
+
function isWellFormedParam(param) {
|
|
497
|
+
const eq = param.indexOf("=");
|
|
498
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim();
|
|
499
|
+
if (key.includes('"')) return false;
|
|
500
|
+
if (eq === -1) return true;
|
|
501
|
+
const value = param.slice(eq + 1).trim();
|
|
502
|
+
if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
|
|
503
|
+
if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
|
|
504
|
+
return !/[\s"]/.test(value);
|
|
505
|
+
}
|
|
506
|
+
function splitOutsideStrings(input, sep) {
|
|
507
|
+
const parts = [];
|
|
508
|
+
let current = "";
|
|
509
|
+
let inString = false;
|
|
510
|
+
let inDisplay = false;
|
|
511
|
+
let escaped = false;
|
|
512
|
+
let prev = "";
|
|
513
|
+
for (const ch of input) {
|
|
514
|
+
if (escaped) escaped = false;
|
|
515
|
+
else if (inString && !inDisplay && ch === "\\") escaped = true;
|
|
516
|
+
else if (ch === '"') {
|
|
517
|
+
if (!inString) inDisplay = prev === "%";
|
|
518
|
+
inString = !inString;
|
|
519
|
+
} else if (ch === sep && !inString) {
|
|
520
|
+
parts.push(current);
|
|
521
|
+
current = "";
|
|
522
|
+
prev = ch;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
current += ch;
|
|
526
|
+
prev = ch;
|
|
527
|
+
}
|
|
528
|
+
parts.push(current);
|
|
529
|
+
return parts;
|
|
530
|
+
}
|
|
531
|
+
function readParams(params) {
|
|
532
|
+
const values = /* @__PURE__ */ new Map();
|
|
533
|
+
let unreadable = false;
|
|
534
|
+
let valuelessAt = -1;
|
|
535
|
+
for (const [index, param] of params.entries()) {
|
|
536
|
+
const eq = param.indexOf("=");
|
|
537
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
|
|
538
|
+
if (!key) continue;
|
|
539
|
+
if (eq === -1) {
|
|
540
|
+
if (key === "r") unreadable = true;
|
|
541
|
+
if (key === "t") values.delete("t");
|
|
542
|
+
valuelessAt = index;
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
|
|
546
|
+
unreadable = true;
|
|
547
|
+
}
|
|
548
|
+
values.set(key, param.slice(eq + 1));
|
|
549
|
+
}
|
|
550
|
+
return { values, unreadable };
|
|
551
|
+
}
|
|
552
|
+
function parseExhaustedWindows(header) {
|
|
553
|
+
if (!header) return { windows: [], dropped: false };
|
|
554
|
+
const windows = [];
|
|
555
|
+
let dropped = false;
|
|
556
|
+
for (const item of splitOutsideStrings(header, ",")) {
|
|
557
|
+
if (item.trim() === "") continue;
|
|
558
|
+
const [rawName, ...params] = splitOutsideStrings(item, ";");
|
|
559
|
+
if (!params.every(isWellFormedParam)) {
|
|
560
|
+
dropped = true;
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const { values, unreadable } = readParams(params);
|
|
564
|
+
if (unreadable) {
|
|
565
|
+
dropped = true;
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const remaining = remainingRequests(values.get("r"));
|
|
569
|
+
if (remaining === null) {
|
|
570
|
+
dropped = true;
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
if (remaining !== 0) continue;
|
|
574
|
+
windows.push({
|
|
575
|
+
window: windowName(rawName),
|
|
576
|
+
resetS: waitSeconds(values.get("t"))
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return { windows, dropped };
|
|
580
|
+
}
|
|
581
|
+
function humanWait(seconds) {
|
|
582
|
+
if (seconds < 120) return `~${seconds}s`;
|
|
583
|
+
if (seconds < 3600)
|
|
584
|
+
return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
|
|
585
|
+
if (seconds < 2 * 86400) {
|
|
586
|
+
const hours = Math.ceil(seconds / 3600);
|
|
587
|
+
return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
|
|
588
|
+
}
|
|
589
|
+
return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
|
|
590
|
+
}
|
|
591
|
+
function rateLimitHint(rateLimitHeader, retryAfterHeader) {
|
|
592
|
+
const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
|
|
593
|
+
windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
|
|
594
|
+
const longestWindow = windows[0];
|
|
595
|
+
const waitCandidates = [
|
|
596
|
+
waitSeconds(retryAfterHeader),
|
|
597
|
+
longestWindow?.resetS
|
|
598
|
+
].filter((n) => typeof n === "number" && n > 0);
|
|
599
|
+
const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
|
|
600
|
+
const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
|
|
601
|
+
if (!longestWindow && waitS === null) {
|
|
602
|
+
if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
|
|
603
|
+
}
|
|
604
|
+
const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
|
|
605
|
+
if (waitS === null) {
|
|
606
|
+
const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
|
|
607
|
+
return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
|
|
608
|
+
}
|
|
609
|
+
const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
|
|
610
|
+
const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
|
|
611
|
+
return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
|
|
612
|
+
}
|
|
613
|
+
|
|
430
614
|
// src/_shared/http.ts
|
|
615
|
+
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";
|
|
431
616
|
var HttpError = class extends Error {
|
|
432
|
-
constructor(prefix, status, body) {
|
|
433
|
-
const
|
|
434
|
-
super(`${prefix}: ${status} - ${
|
|
617
|
+
constructor(prefix, status, body, hint) {
|
|
618
|
+
const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
|
|
619
|
+
super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
|
|
435
620
|
this.name = "HttpError";
|
|
436
621
|
this.status = status;
|
|
437
622
|
this.body = body;
|
|
438
623
|
}
|
|
439
624
|
};
|
|
625
|
+
var DEFAULT_REQUEST_TIMEOUT_MS = 3e4;
|
|
626
|
+
var MIN_REQUEST_TIMEOUT_MS = 1e3;
|
|
627
|
+
var RequestTimeoutError = class extends Error {
|
|
628
|
+
constructor(prefix, timeoutMs, method) {
|
|
629
|
+
const write = method !== "GET" && method !== "HEAD";
|
|
630
|
+
super(
|
|
631
|
+
`${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."}`
|
|
632
|
+
);
|
|
633
|
+
this.name = "RequestTimeoutError";
|
|
634
|
+
this.timeoutMs = timeoutMs;
|
|
635
|
+
this.method = method;
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
async function withRequestTimeout(errorPrefix, timeoutMs, method, run) {
|
|
639
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
640
|
+
try {
|
|
641
|
+
return await run(signal);
|
|
642
|
+
} catch (error) {
|
|
643
|
+
if (error instanceof HttpError || !signal.aborted) throw error;
|
|
644
|
+
throw new RequestTimeoutError(errorPrefix, timeoutMs, method);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
440
647
|
function isJsonContentType(contentType) {
|
|
441
648
|
if (!contentType) return false;
|
|
442
649
|
const ct = contentType.toLowerCase();
|
|
@@ -452,24 +659,49 @@ function buildRequestHeaders(apiKey, tracking) {
|
|
|
452
659
|
...tracking()
|
|
453
660
|
};
|
|
454
661
|
}
|
|
662
|
+
function resolveCeiling(ceiling) {
|
|
663
|
+
if (ceiling === void 0) return Infinity;
|
|
664
|
+
if (typeof ceiling !== "function") return ceiling;
|
|
665
|
+
return Math.max(ceiling(), MIN_REQUEST_TIMEOUT_MS);
|
|
666
|
+
}
|
|
667
|
+
function clampTimeout(requestedMs, ceiling, defaultMs = DEFAULT_REQUEST_TIMEOUT_MS) {
|
|
668
|
+
return Math.min(requestedMs ?? defaultMs, resolveCeiling(ceiling));
|
|
669
|
+
}
|
|
455
670
|
function createHttpClient(deps) {
|
|
456
|
-
|
|
457
|
-
|
|
671
|
+
const defaultTimeoutMs = deps.defaultTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
672
|
+
return async function request(url, method = "GET", body, options) {
|
|
673
|
+
const timeoutMs = clampTimeout(
|
|
674
|
+
options?.timeoutMs,
|
|
675
|
+
deps.maxTimeoutMs,
|
|
676
|
+
defaultTimeoutMs
|
|
677
|
+
);
|
|
678
|
+
return withRequestTimeout(
|
|
679
|
+
deps.errorPrefix,
|
|
680
|
+
timeoutMs,
|
|
458
681
|
method,
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
response.
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
682
|
+
async (signal) => {
|
|
683
|
+
const init = {
|
|
684
|
+
method,
|
|
685
|
+
headers: buildRequestHeaders(deps.apiKey, deps.tracking),
|
|
686
|
+
signal
|
|
687
|
+
};
|
|
688
|
+
if (body && methodSendsBody(method)) {
|
|
689
|
+
init.body = JSON.stringify(body);
|
|
690
|
+
}
|
|
691
|
+
const response = await deps.fetch(url, init);
|
|
692
|
+
if (!response.ok) {
|
|
693
|
+
const hint = response.status === 429 ? rateLimitHint(
|
|
694
|
+
response.headers.get("ratelimit"),
|
|
695
|
+
response.headers.get("retry-after")
|
|
696
|
+
) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0;
|
|
697
|
+
const text = await response.text().catch(
|
|
698
|
+
() => "<body unavailable: the connection closed while reading it>"
|
|
699
|
+
);
|
|
700
|
+
throw new HttpError(deps.errorPrefix, response.status, text, hint);
|
|
701
|
+
}
|
|
702
|
+
return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
|
|
703
|
+
}
|
|
704
|
+
);
|
|
473
705
|
};
|
|
474
706
|
}
|
|
475
707
|
|
|
@@ -525,7 +757,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
|
|
|
525
757
|
throw new HttpError(
|
|
526
758
|
"Runpod API Error",
|
|
527
759
|
res.status,
|
|
528
|
-
await res.text().catch(() => "")
|
|
760
|
+
await res.text().catch(() => ""),
|
|
761
|
+
// This throw bypasses createHttpClient, so the status hints are built
|
|
762
|
+
// here too or these two tools alone return a bare 429/401. The 401
|
|
763
|
+
// hint applies: this request always carries Authorization (see the
|
|
764
|
+
// headers bound in runtime.ts streamSse).
|
|
765
|
+
res.status === 429 ? rateLimitHint(
|
|
766
|
+
res.headers.get("ratelimit"),
|
|
767
|
+
res.headers.get("retry-after")
|
|
768
|
+
) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
529
769
|
);
|
|
530
770
|
}
|
|
531
771
|
for await (const chunk of res.body ?? []) {
|
|
@@ -588,7 +828,7 @@ async function streamLogsReply(rt, tool, params) {
|
|
|
588
828
|
}
|
|
589
829
|
|
|
590
830
|
// src/tools/runtime.ts
|
|
591
|
-
var MCP_SERVER_VERSION = true ? "3.
|
|
831
|
+
var MCP_SERVER_VERSION = true ? "3.3.0" : "dev";
|
|
592
832
|
var SESSION_ID = (0, import_node_crypto.randomUUID)();
|
|
593
833
|
function trackingHeaders(server, ctx) {
|
|
594
834
|
const info = server.server.getClientVersion();
|
|
@@ -603,6 +843,11 @@ function trackingHeaders(server, ctx) {
|
|
|
603
843
|
sessionId: SESSION_ID
|
|
604
844
|
});
|
|
605
845
|
}
|
|
846
|
+
var HTTP_TRANSPORT_BUDGET_MS = 5e4;
|
|
847
|
+
function invocationBudget(totalMs) {
|
|
848
|
+
const startedAt = Date.now();
|
|
849
|
+
return () => totalMs - (Date.now() - startedAt);
|
|
850
|
+
}
|
|
606
851
|
var defaultFetch = import_node_fetch.default;
|
|
607
852
|
async function withApiErrorLog(label, call) {
|
|
608
853
|
try {
|
|
@@ -626,6 +871,23 @@ var DESTRUCTIVE = {
|
|
|
626
871
|
openWorldHint: true
|
|
627
872
|
};
|
|
628
873
|
async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
874
|
+
return withRequestTimeout(
|
|
875
|
+
"Runpod GraphQL Error",
|
|
876
|
+
clampTimeout(options?.timeoutMs, options?.maxTimeoutMs),
|
|
877
|
+
// GraphQL is POST on the wire whatever it carries, but the retry advice keys
|
|
878
|
+
// off the method, and telling a caller its list-gpu-types "may have
|
|
879
|
+
// SUCCEEDED upstream — check with the matching list-/get- tool first" points
|
|
880
|
+
// it back at the tool that just failed. The operation keyword is what
|
|
881
|
+
// actually says whether anything could have been written.
|
|
882
|
+
isGraphqlMutation(query) ? "POST" : "GET",
|
|
883
|
+
(signal) => runGraphql(query, tracking, fetchImpl, url, signal, options)
|
|
884
|
+
);
|
|
885
|
+
}
|
|
886
|
+
function isGraphqlMutation(query) {
|
|
887
|
+
const firstMeaningful = query.split("\n").map((line) => line.trim()).find((line) => line.length > 0 && !line.startsWith("#"));
|
|
888
|
+
return /^mutation\b/.test(firstMeaningful ?? "");
|
|
889
|
+
}
|
|
890
|
+
async function runGraphql(query, tracking, fetchImpl, url, signal, options) {
|
|
629
891
|
const response = await fetchImpl(url, {
|
|
630
892
|
method: "POST",
|
|
631
893
|
headers: {
|
|
@@ -636,8 +898,39 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
|
636
898
|
body: JSON.stringify({
|
|
637
899
|
query,
|
|
638
900
|
...options?.variables ? { variables: options.variables } : {}
|
|
639
|
-
})
|
|
901
|
+
}),
|
|
902
|
+
signal
|
|
640
903
|
});
|
|
904
|
+
if (!response.ok) {
|
|
905
|
+
const bodyText = await response.text();
|
|
906
|
+
let gqlErrors;
|
|
907
|
+
try {
|
|
908
|
+
gqlErrors = JSON.parse(bodyText).errors;
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
|
|
912
|
+
throw new HttpError(
|
|
913
|
+
"Runpod GraphQL Error",
|
|
914
|
+
response.status,
|
|
915
|
+
gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
throw new HttpError(
|
|
919
|
+
"Runpod GraphQL Error",
|
|
920
|
+
response.status,
|
|
921
|
+
bodyText,
|
|
922
|
+
response.status === 429 ? rateLimitHint(
|
|
923
|
+
response.headers.get("ratelimit"),
|
|
924
|
+
response.headers.get("retry-after")
|
|
925
|
+
) : (
|
|
926
|
+
// Only when this request actually carried the caller's API key: the
|
|
927
|
+
// public path sends no Authorization header, so a 401 from that
|
|
928
|
+
// host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
|
|
929
|
+
// about the credential — same rationale as observeUnauthorized.
|
|
930
|
+
response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
931
|
+
)
|
|
932
|
+
);
|
|
933
|
+
}
|
|
641
934
|
const result = await response.json();
|
|
642
935
|
if (result.errors && result.errors.length > 0) {
|
|
643
936
|
throw new Error(
|
|
@@ -662,39 +955,53 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
662
955
|
}
|
|
663
956
|
const rawFetch = deps.fetch ?? defaultFetch;
|
|
664
957
|
const httpFetch = observeUnauthorized(rawFetch);
|
|
958
|
+
const remainingHttpBudgetMs = invocationBudget(HTTP_TRANSPORT_BUDGET_MS);
|
|
959
|
+
const timeouts = {
|
|
960
|
+
defaultTimeoutMs: deps.defaultTimeoutMs,
|
|
961
|
+
maxTimeoutMs: deps.maxTimeoutMs ?? (ctx.transport === "http" ? remainingHttpBudgetMs : void 0)
|
|
962
|
+
};
|
|
665
963
|
const v1Client = createHttpClient({
|
|
666
964
|
apiKey: ctx.apiKey,
|
|
667
965
|
fetch: httpFetch,
|
|
668
966
|
tracking,
|
|
669
|
-
errorPrefix: "Runpod API Error"
|
|
967
|
+
errorPrefix: "Runpod API Error",
|
|
968
|
+
...timeouts
|
|
670
969
|
});
|
|
671
|
-
const runpodRequest = (endpoint, method = "GET", body) => withApiErrorLog(
|
|
970
|
+
const runpodRequest = (endpoint, method = "GET", body, options) => withApiErrorLog(
|
|
672
971
|
"Error calling Runpod API:",
|
|
673
|
-
() => v1Client(
|
|
972
|
+
() => v1Client(
|
|
973
|
+
`${restV1Base(process.env)}${endpoint}`,
|
|
974
|
+
method,
|
|
975
|
+
body,
|
|
976
|
+
options
|
|
977
|
+
)
|
|
674
978
|
);
|
|
675
979
|
const serverlessClient = createHttpClient({
|
|
676
980
|
apiKey: ctx.apiKey,
|
|
677
981
|
fetch: httpFetch,
|
|
678
982
|
tracking,
|
|
679
|
-
errorPrefix: "Runpod Serverless API Error"
|
|
983
|
+
errorPrefix: "Runpod Serverless API Error",
|
|
984
|
+
...timeouts
|
|
680
985
|
});
|
|
681
|
-
const serverlessRequest = (endpointId, path, method = "GET", body) => withApiErrorLog(
|
|
986
|
+
const serverlessRequest = (endpointId, path, method = "GET", body, options) => withApiErrorLog(
|
|
682
987
|
"Error calling Runpod Serverless API:",
|
|
683
988
|
() => serverlessClient(
|
|
684
989
|
`${serverlessBase(process.env)}/${endpointId}${path}`,
|
|
685
990
|
method,
|
|
686
|
-
body
|
|
991
|
+
body,
|
|
992
|
+
options
|
|
687
993
|
)
|
|
688
994
|
);
|
|
689
995
|
const restClient = createHttpClient({
|
|
690
996
|
apiKey: ctx.apiKey,
|
|
691
997
|
fetch: httpFetch,
|
|
692
998
|
tracking,
|
|
693
|
-
errorPrefix: "Runpod API Error"
|
|
999
|
+
errorPrefix: "Runpod API Error",
|
|
1000
|
+
...timeouts
|
|
694
1001
|
});
|
|
695
|
-
const callRestUrl = (url, method = "GET", body) => withApiErrorLog(
|
|
1002
|
+
const callRestUrl = (url, method = "GET", body, options) => withApiErrorLog(
|
|
696
1003
|
"Error calling Runpod API:",
|
|
697
|
-
() => restClient(url, method, body)
|
|
1004
|
+
() => restClient(url, method, body, options)
|
|
698
1005
|
);
|
|
699
1006
|
const sseFetch = observeUnauthorized(
|
|
700
1007
|
deps.sseFetch ?? import_node_fetch.default
|
|
@@ -727,12 +1034,18 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
727
1034
|
};
|
|
728
1035
|
return {
|
|
729
1036
|
jsonReply,
|
|
1037
|
+
// No GraphQL caller overrides a deadline, but both are still capped by the
|
|
1038
|
+
// shared budget: deploy-hub-repo spends one of each back to back.
|
|
730
1039
|
graphql: (query) => graphqlRequest(
|
|
731
1040
|
query,
|
|
732
1041
|
tracking,
|
|
733
1042
|
// rawFetch, not httpFetch: this call carries no credential (see above).
|
|
734
1043
|
rawFetch,
|
|
735
|
-
publicGraphqlBase(process.env)
|
|
1044
|
+
publicGraphqlBase(process.env),
|
|
1045
|
+
{
|
|
1046
|
+
timeoutMs: deps.defaultTimeoutMs,
|
|
1047
|
+
maxTimeoutMs: timeouts.maxTimeoutMs
|
|
1048
|
+
}
|
|
736
1049
|
),
|
|
737
1050
|
graphqlAuthed: (query, variables) => graphqlRequest(
|
|
738
1051
|
query,
|
|
@@ -740,7 +1053,12 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
740
1053
|
httpFetch,
|
|
741
1054
|
// NOT publicGraphqlBase: this call carries the caller's API key.
|
|
742
1055
|
authedGraphqlBase(process.env),
|
|
743
|
-
{
|
|
1056
|
+
{
|
|
1057
|
+
variables,
|
|
1058
|
+
apiKey: ctx.apiKey,
|
|
1059
|
+
timeoutMs: deps.defaultTimeoutMs,
|
|
1060
|
+
maxTimeoutMs: timeouts.maxTimeoutMs
|
|
1061
|
+
}
|
|
744
1062
|
),
|
|
745
1063
|
runpodRequest,
|
|
746
1064
|
serverlessRequest,
|
|
@@ -748,7 +1066,8 @@ function createToolRuntime(server, ctx, deps = {}) {
|
|
|
748
1066
|
backendFor,
|
|
749
1067
|
streamSse,
|
|
750
1068
|
podAction,
|
|
751
|
-
env: process.env
|
|
1069
|
+
env: process.env,
|
|
1070
|
+
transport: ctx.transport
|
|
752
1071
|
};
|
|
753
1072
|
}
|
|
754
1073
|
|
|
@@ -817,6 +1136,7 @@ function capListResult(result, options, extra) {
|
|
|
817
1136
|
}
|
|
818
1137
|
|
|
819
1138
|
// src/tools/catalog.ts
|
|
1139
|
+
var GPU_PRODUCTS = /* @__PURE__ */ new Set(["POD", "CLUSTER", "SERVERLESS"]);
|
|
820
1140
|
function registerCatalogTools(server, rt) {
|
|
821
1141
|
const { jsonReply: jsonReply2, graphql, callRestUrl, backendFor } = rt;
|
|
822
1142
|
server.tool(
|
|
@@ -835,6 +1155,9 @@ function registerCatalogTools(server, rt) {
|
|
|
835
1155
|
),
|
|
836
1156
|
includeAvailability: import_zod3.z.boolean().optional().describe(
|
|
837
1157
|
"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."
|
|
1158
|
+
),
|
|
1159
|
+
product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
|
|
1160
|
+
"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."
|
|
838
1161
|
)
|
|
839
1162
|
},
|
|
840
1163
|
{ title: "List GPU types", ...READ_ONLY },
|
|
@@ -842,8 +1165,9 @@ function registerCatalogTools(server, rt) {
|
|
|
842
1165
|
const backend = backendFor("gpus");
|
|
843
1166
|
if (backend.version === "v2") {
|
|
844
1167
|
const wantAvailability = params.includeAvailability !== false;
|
|
1168
|
+
const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
|
|
845
1169
|
const raw = await callRestUrl(
|
|
846
|
-
`${backend.base}${backend.list}${wantAvailability ?
|
|
1170
|
+
`${backend.base}${backend.list}${wantAvailability ? `?include=AVAILABILITY&product=${product}` : ""}`
|
|
847
1171
|
);
|
|
848
1172
|
let gpus = backend.unwrap(raw);
|
|
849
1173
|
gpus = gpus.filter((g) => g.id !== "unknown");
|
|
@@ -1016,6 +1340,9 @@ function registerCatalogTools(server, rt) {
|
|
|
1016
1340
|
gpuTypeId: import_zod3.z.string().describe("ID of the GPU type to retrieve"),
|
|
1017
1341
|
includeAvailability: import_zod3.z.boolean().optional().describe(
|
|
1018
1342
|
"Include realtime per-datacenter availability (HIGH/MEDIUM/LOW/NONE). Default true."
|
|
1343
|
+
),
|
|
1344
|
+
product: import_zod3.z.enum(["POD", "CLUSTER", "SERVERLESS"]).optional().describe(
|
|
1345
|
+
"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."
|
|
1019
1346
|
)
|
|
1020
1347
|
},
|
|
1021
1348
|
{ title: "Get GPU type", ...READ_ONLY },
|
|
@@ -1028,7 +1355,8 @@ function registerCatalogTools(server, rt) {
|
|
|
1028
1355
|
});
|
|
1029
1356
|
}
|
|
1030
1357
|
const path = backend.get(encodeURIComponent(params.gpuTypeId));
|
|
1031
|
-
const
|
|
1358
|
+
const product = GPU_PRODUCTS.has(String(params.product)) ? String(params.product) : "POD";
|
|
1359
|
+
const query = params.includeAvailability === false ? "" : `?include=AVAILABILITY&product=${product}`;
|
|
1032
1360
|
const result = await callRestUrl(`${backend.base}${path}${query}`);
|
|
1033
1361
|
return jsonReply2(result);
|
|
1034
1362
|
}
|
|
@@ -1054,6 +1382,178 @@ function registerCatalogTools(server, rt) {
|
|
|
1054
1382
|
return jsonReply2(result);
|
|
1055
1383
|
}
|
|
1056
1384
|
);
|
|
1385
|
+
server.tool(
|
|
1386
|
+
"get-capacity",
|
|
1387
|
+
"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.",
|
|
1388
|
+
{
|
|
1389
|
+
...listPaginationParams,
|
|
1390
|
+
cudaVersions: import_zod3.z.array(import_zod3.z.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
|
|
1391
|
+
'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.'
|
|
1392
|
+
),
|
|
1393
|
+
includeUnavailable: import_zod3.z.boolean().optional().describe(
|
|
1394
|
+
'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.'
|
|
1395
|
+
),
|
|
1396
|
+
gpuTypeIds: import_zod3.z.array(import_zod3.z.string()).optional().describe(
|
|
1397
|
+
"Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
|
|
1398
|
+
),
|
|
1399
|
+
gpuCount: import_zod3.z.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
|
|
1400
|
+
secureCloudOnly: import_zod3.z.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
|
|
1401
|
+
},
|
|
1402
|
+
{ title: "Get GPU capacity by CUDA version", ...READ_ONLY },
|
|
1403
|
+
async (params) => {
|
|
1404
|
+
const gpuCount = Math.min(
|
|
1405
|
+
8,
|
|
1406
|
+
Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
|
|
1407
|
+
);
|
|
1408
|
+
const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
|
|
1409
|
+
const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
|
|
1410
|
+
const matchesFilter = (gpu) => {
|
|
1411
|
+
if (filterTerms.length === 0) return true;
|
|
1412
|
+
const id = gpu.id.toLowerCase();
|
|
1413
|
+
const name = gpu.displayName.toLowerCase();
|
|
1414
|
+
return filterTerms.some(
|
|
1415
|
+
(term) => id.includes(term) || name.includes(term)
|
|
1416
|
+
);
|
|
1417
|
+
};
|
|
1418
|
+
const stockPriority = {
|
|
1419
|
+
High: 3,
|
|
1420
|
+
Medium: 2,
|
|
1421
|
+
Low: 1
|
|
1422
|
+
};
|
|
1423
|
+
if (!params.cudaVersions?.length) {
|
|
1424
|
+
const data = await graphql(`
|
|
1425
|
+
query {
|
|
1426
|
+
gpuTypes {
|
|
1427
|
+
id
|
|
1428
|
+
displayName
|
|
1429
|
+
memoryInGb
|
|
1430
|
+
secureCloud
|
|
1431
|
+
communityCloud
|
|
1432
|
+
lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
|
|
1433
|
+
stockStatus
|
|
1434
|
+
uninterruptablePrice
|
|
1435
|
+
gpuTypeCudaVersions {
|
|
1436
|
+
cudaVersion
|
|
1437
|
+
availability
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
`);
|
|
1443
|
+
let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
|
|
1444
|
+
const cuda = {};
|
|
1445
|
+
for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
|
|
1446
|
+
cuda[c.cudaVersion] = c.availability;
|
|
1447
|
+
}
|
|
1448
|
+
return {
|
|
1449
|
+
id: gpu.id,
|
|
1450
|
+
displayName: gpu.displayName,
|
|
1451
|
+
memoryGb: gpu.memoryInGb,
|
|
1452
|
+
secureCloud: gpu.secureCloud,
|
|
1453
|
+
communityCloud: gpu.communityCloud,
|
|
1454
|
+
stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
|
|
1455
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
|
|
1456
|
+
cudaVersions: cuda
|
|
1457
|
+
};
|
|
1458
|
+
});
|
|
1459
|
+
const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
|
|
1460
|
+
if (params.includeUnavailable === false)
|
|
1461
|
+
rows2 = rows2.filter((r) => availableCount(r) > 0);
|
|
1462
|
+
rows2.sort((a, b) => {
|
|
1463
|
+
const diff = availableCount(b) - availableCount(a);
|
|
1464
|
+
if (diff !== 0) return diff;
|
|
1465
|
+
const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
|
|
1466
|
+
if (stockDiff !== 0) return stockDiff;
|
|
1467
|
+
return b.memoryGb - a.memoryGb;
|
|
1468
|
+
});
|
|
1469
|
+
return capListResult(rows2, {
|
|
1470
|
+
limit: params.limit,
|
|
1471
|
+
cursor: params.cursor
|
|
1472
|
+
});
|
|
1473
|
+
}
|
|
1474
|
+
const versions = [...new Set(params.cudaVersions)].slice(0, 12);
|
|
1475
|
+
const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
|
|
1476
|
+
if (invalid.length > 0) {
|
|
1477
|
+
return jsonReply2({
|
|
1478
|
+
error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
|
|
1479
|
+
status: 400
|
|
1480
|
+
});
|
|
1481
|
+
}
|
|
1482
|
+
const perVersion = await Promise.allSettled(
|
|
1483
|
+
versions.map(
|
|
1484
|
+
(v) => graphql(`
|
|
1485
|
+
query {
|
|
1486
|
+
gpuTypes {
|
|
1487
|
+
id
|
|
1488
|
+
displayName
|
|
1489
|
+
memoryInGb
|
|
1490
|
+
secureCloud
|
|
1491
|
+
communityCloud
|
|
1492
|
+
lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
|
|
1493
|
+
stockStatus
|
|
1494
|
+
uninterruptablePrice
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
`)
|
|
1499
|
+
)
|
|
1500
|
+
);
|
|
1501
|
+
const probeErrors = {};
|
|
1502
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1503
|
+
versions.forEach((v, i) => {
|
|
1504
|
+
const settled = perVersion[i];
|
|
1505
|
+
if (settled.status === "rejected") {
|
|
1506
|
+
probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
for (const gpu of settled.value.gpuTypes) {
|
|
1510
|
+
if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
|
|
1511
|
+
let row = byId.get(gpu.id);
|
|
1512
|
+
if (!row) {
|
|
1513
|
+
row = {
|
|
1514
|
+
id: gpu.id,
|
|
1515
|
+
displayName: gpu.displayName,
|
|
1516
|
+
memoryGb: gpu.memoryInGb,
|
|
1517
|
+
secureCloud: gpu.secureCloud,
|
|
1518
|
+
communityCloud: gpu.communityCloud,
|
|
1519
|
+
cudaVersions: {}
|
|
1520
|
+
};
|
|
1521
|
+
byId.set(gpu.id, row);
|
|
1522
|
+
}
|
|
1523
|
+
const stock = gpu.lowestPrice?.stockStatus;
|
|
1524
|
+
row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
|
|
1525
|
+
stock,
|
|
1526
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
|
|
1527
|
+
};
|
|
1528
|
+
}
|
|
1529
|
+
});
|
|
1530
|
+
let rows = [...byId.values()];
|
|
1531
|
+
const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
|
|
1532
|
+
if (params.includeUnavailable === false)
|
|
1533
|
+
rows = rows.filter((r) => inStockCount(r) > 0);
|
|
1534
|
+
rows.sort((a, b) => {
|
|
1535
|
+
const diff = inStockCount(b) - inStockCount(a);
|
|
1536
|
+
if (diff !== 0) return diff;
|
|
1537
|
+
const best = (r) => Math.max(
|
|
1538
|
+
0,
|
|
1539
|
+
...Object.values(r.cudaVersions).map(
|
|
1540
|
+
(c) => stockPriority[c.stock] || 0
|
|
1541
|
+
)
|
|
1542
|
+
);
|
|
1543
|
+
const bestDiff = best(b) - best(a);
|
|
1544
|
+
if (bestDiff !== 0) return bestDiff;
|
|
1545
|
+
return b.memoryGb - a.memoryGb;
|
|
1546
|
+
});
|
|
1547
|
+
return capListResult(
|
|
1548
|
+
rows,
|
|
1549
|
+
{ limit: params.limit, cursor: params.cursor },
|
|
1550
|
+
{
|
|
1551
|
+
probedCudaVersions: versions,
|
|
1552
|
+
...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
|
|
1553
|
+
}
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
);
|
|
1057
1557
|
server.tool(
|
|
1058
1558
|
"get-data-center",
|
|
1059
1559
|
"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).",
|
|
@@ -1736,9 +2236,11 @@ function registerPodTools(server, rt) {
|
|
|
1736
2236
|
);
|
|
1737
2237
|
server.tool(
|
|
1738
2238
|
"create-pod",
|
|
1739
|
-
'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.',
|
|
2239
|
+
'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.',
|
|
1740
2240
|
{
|
|
1741
|
-
name: import_zod7.z.string().optional().describe(
|
|
2241
|
+
name: import_zod7.z.string().optional().describe(
|
|
2242
|
+
"Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
|
|
2243
|
+
),
|
|
1742
2244
|
imageName: import_zod7.z.string().optional().describe("Docker image to use. Optional when templateId is provided."),
|
|
1743
2245
|
templateId: import_zod7.z.string().optional().describe(
|
|
1744
2246
|
"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."
|
|
@@ -1748,12 +2250,18 @@ function registerPodTools(server, rt) {
|
|
|
1748
2250
|
),
|
|
1749
2251
|
cloudType: import_zod7.z.enum(["SECURE", "COMMUNITY"]).optional().describe("SECURE or COMMUNITY cloud"),
|
|
1750
2252
|
gpuTypeIds: import_zod7.z.array(import_zod7.z.string()).optional().describe("List of acceptable GPU types"),
|
|
1751
|
-
|
|
2253
|
+
// v2 GpuConfig.count is `integer, minimum: 1`. Without these bounds a 0,
|
|
2254
|
+
// a negative, or a fractional count reached the wire as-is and came back
|
|
2255
|
+
// as a raw 422; reject it here with a message that names the field.
|
|
2256
|
+
gpuCount: import_zod7.z.number().int().min(1).optional().describe("Number of GPUs (minimum 1)"),
|
|
1752
2257
|
containerDiskInGb: import_zod7.z.number().optional().describe("Container disk size in GB"),
|
|
1753
2258
|
volumeInGb: import_zod7.z.number().optional().describe("Volume size in GB"),
|
|
1754
2259
|
volumeMountPath: import_zod7.z.string().optional().describe("Path to mount the volume"),
|
|
1755
2260
|
ports: import_zod7.z.array(import_zod7.z.string()).optional().describe("Ports to expose (e.g., '8888/http', '22/tcp')"),
|
|
1756
2261
|
env: import_zod7.z.record(import_zod7.z.string()).optional().describe("Environment variables"),
|
|
2262
|
+
sshPublicKey: import_zod7.z.string().optional().describe(
|
|
2263
|
+
'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.'
|
|
2264
|
+
),
|
|
1757
2265
|
containerRegistryAuthId: import_zod7.z.string().optional().describe(
|
|
1758
2266
|
"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)."
|
|
1759
2267
|
),
|
|
@@ -1762,6 +2270,15 @@ function registerPodTools(server, rt) {
|
|
|
1762
2270
|
{ title: "Create pod", ...WRITE },
|
|
1763
2271
|
async (params) => {
|
|
1764
2272
|
const backend = backendFor("pods");
|
|
2273
|
+
const { sshPublicKey, ...podParams } = params;
|
|
2274
|
+
let sshKey;
|
|
2275
|
+
if (sshPublicKey !== void 0) {
|
|
2276
|
+
const validated = normalizeSshPublicKey(sshPublicKey);
|
|
2277
|
+
if (!validated.ok)
|
|
2278
|
+
return jsonReply2({ error: validated.error, status: 400 });
|
|
2279
|
+
sshKey = validated.key;
|
|
2280
|
+
}
|
|
2281
|
+
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.";
|
|
1765
2282
|
if (params.templateId && backend.version !== "v2") {
|
|
1766
2283
|
return jsonReply2({
|
|
1767
2284
|
error: "Deploying a pod from a templateId is only supported on the v2 REST API. Set RUNPOD_REST_VERSION=v2, or pass imageName instead.",
|
|
@@ -1797,6 +2314,12 @@ function registerPodTools(server, rt) {
|
|
|
1797
2314
|
status: 400
|
|
1798
2315
|
});
|
|
1799
2316
|
}
|
|
2317
|
+
if (!wantsCpu && !params.name && !params.templateId) {
|
|
2318
|
+
return jsonReply2({
|
|
2319
|
+
error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
|
|
2320
|
+
status: 400
|
|
2321
|
+
});
|
|
2322
|
+
}
|
|
1800
2323
|
if (wantsCpu) {
|
|
1801
2324
|
if (params.templateId) {
|
|
1802
2325
|
return jsonReply2({
|
|
@@ -1805,13 +2328,15 @@ function registerPodTools(server, rt) {
|
|
|
1805
2328
|
});
|
|
1806
2329
|
}
|
|
1807
2330
|
try {
|
|
2331
|
+
const cpuBody = sshKey ? applySshPublicKey(podParams, sshKey) : podParams;
|
|
1808
2332
|
const result = await callRestUrl(
|
|
1809
2333
|
`${restV1Base(env)}/pods`,
|
|
1810
2334
|
"POST",
|
|
1811
|
-
|
|
2335
|
+
cpuBody
|
|
1812
2336
|
);
|
|
1813
2337
|
return jsonReply2({
|
|
1814
2338
|
...result,
|
|
2339
|
+
...sshKey ? { _ssh: sshNote } : {},
|
|
1815
2340
|
_servedBy: "v1",
|
|
1816
2341
|
_note: "CPU pod: created on the v1 API, because the v2 REST API does not support CPU pods yet."
|
|
1817
2342
|
});
|
|
@@ -1828,7 +2353,7 @@ function registerPodTools(server, rt) {
|
|
|
1828
2353
|
}
|
|
1829
2354
|
}
|
|
1830
2355
|
}
|
|
1831
|
-
let body = backend.mapCreate(
|
|
2356
|
+
let body = backend.mapCreate(podParams);
|
|
1832
2357
|
if (params.templateId) {
|
|
1833
2358
|
let template;
|
|
1834
2359
|
try {
|
|
@@ -1846,19 +2371,21 @@ function registerPodTools(server, rt) {
|
|
|
1846
2371
|
}
|
|
1847
2372
|
body = { ...podBodyFromTemplate(template), ...body };
|
|
1848
2373
|
}
|
|
2374
|
+
if (sshKey) body = applySshPublicKey(body, sshKey);
|
|
1849
2375
|
try {
|
|
1850
2376
|
const result = await callRestUrl(
|
|
1851
2377
|
`${backend.base}${backend.list}`,
|
|
1852
2378
|
"POST",
|
|
1853
2379
|
body
|
|
1854
2380
|
);
|
|
2381
|
+
const extras = {};
|
|
2382
|
+
if (sshKey) extras._ssh = sshNote;
|
|
1855
2383
|
if (backend.version === "v2" && (params.gpuTypeIds?.length ?? 0) > 1) {
|
|
1856
|
-
|
|
1857
|
-
...result,
|
|
1858
|
-
_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(", ")}.`
|
|
1859
|
-
});
|
|
2384
|
+
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(", ")}.`;
|
|
1860
2385
|
}
|
|
1861
|
-
return jsonReply2(
|
|
2386
|
+
return jsonReply2(
|
|
2387
|
+
Object.keys(extras).length ? { ...result, ...extras } : result
|
|
2388
|
+
);
|
|
1862
2389
|
} catch (error) {
|
|
1863
2390
|
if (error instanceof HttpError) {
|
|
1864
2391
|
return jsonReply2({ error: error.message, status: error.status });
|
|
@@ -2319,8 +2846,108 @@ var import_zod9 = require("zod");
|
|
|
2319
2846
|
var DIAGNOSIS_TTL_MS = 15e3;
|
|
2320
2847
|
var MAX_DIAGNOSIS_ENTRIES = 500;
|
|
2321
2848
|
var diagnosisCache = /* @__PURE__ */ new Map();
|
|
2849
|
+
var HTTP_LONG_POLL_BUDGET_MS = 45e3;
|
|
2850
|
+
var STDIO_STREAM_BUDGET_MS = 5 * 60 * 1e3;
|
|
2851
|
+
var RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS = 9e4;
|
|
2852
|
+
var STREAM_UPSTREAM_DEFAULT_WAIT_MS = 1e4;
|
|
2853
|
+
var HTTP_STREAM_POLL_WAIT_MS = 1e3;
|
|
2854
|
+
var STREAM_JOB_POLL_INTERVAL_MS = 1e3;
|
|
2855
|
+
var MAX_CONSECUTIVE_STREAM_ERRORS = 5;
|
|
2856
|
+
var TERMINAL_STATUSES = /* @__PURE__ */ new Set([
|
|
2857
|
+
"COMPLETED",
|
|
2858
|
+
"FAILED",
|
|
2859
|
+
"CANCELLED",
|
|
2860
|
+
"TIMED_OUT"
|
|
2861
|
+
]);
|
|
2862
|
+
function isUnfinishedJobReply(result) {
|
|
2863
|
+
return result !== null && typeof result === "object" && !Array.isArray(result) && !("waitClamped" in result) && !TERMINAL_STATUSES.has(result.status ?? "");
|
|
2864
|
+
}
|
|
2865
|
+
function formatBudget(ms) {
|
|
2866
|
+
return ms >= 12e4 ? `${Math.round(ms / 6e4)} minutes` : `${Math.round(ms / 1e3)} seconds`;
|
|
2867
|
+
}
|
|
2868
|
+
var UPSTREAM_HOLD_SLACK_MS = 5e3;
|
|
2869
|
+
var MIN_STREAM_POLL_TIMEOUT_MS = 2e3;
|
|
2870
|
+
var QUEUED_DIAGNOSIS_TIMEOUT_MS = 5e3;
|
|
2871
|
+
function streamPollTimeoutMs(remainingMs, holdMs) {
|
|
2872
|
+
return Math.max(
|
|
2873
|
+
Math.min(remainingMs, holdMs + UPSTREAM_HOLD_SLACK_MS),
|
|
2874
|
+
MIN_STREAM_POLL_TIMEOUT_MS
|
|
2875
|
+
);
|
|
2876
|
+
}
|
|
2877
|
+
function sleep(ms) {
|
|
2878
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2879
|
+
}
|
|
2880
|
+
function streamPollPlan(hosted) {
|
|
2881
|
+
return hosted ? {
|
|
2882
|
+
budgetMs: HTTP_LONG_POLL_BUDGET_MS,
|
|
2883
|
+
holdMs: HTTP_STREAM_POLL_WAIT_MS,
|
|
2884
|
+
query: `?wait=${HTTP_STREAM_POLL_WAIT_MS}`
|
|
2885
|
+
} : {
|
|
2886
|
+
budgetMs: STDIO_STREAM_BUDGET_MS,
|
|
2887
|
+
holdMs: STREAM_UPSTREAM_DEFAULT_WAIT_MS,
|
|
2888
|
+
query: ""
|
|
2889
|
+
};
|
|
2890
|
+
}
|
|
2891
|
+
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).";
|
|
2892
|
+
function budgetExhaustedNote(budgetMs, lastError) {
|
|
2893
|
+
return {
|
|
2894
|
+
pollingTimedOut: true,
|
|
2895
|
+
note: `Polling stopped after ${formatBudget(
|
|
2896
|
+
budgetMs
|
|
2897
|
+
)} with the job possibly still running. ${RESUME_ADVICE}`,
|
|
2898
|
+
// Surface the trailing error (if any) instead of discarding it — the last
|
|
2899
|
+
// polls may have been failing (e.g. job expired) even though earlier ones
|
|
2900
|
+
// succeeded. Cleared on success, so this is never a stale error from
|
|
2901
|
+
// minutes of healthy streaming ago.
|
|
2902
|
+
...lastError ? { lastError } : {}
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2905
|
+
function errorCapNote(lastError) {
|
|
2906
|
+
return {
|
|
2907
|
+
error: `Polling aborted after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors: ${lastError}`,
|
|
2908
|
+
note: `Polling stopped after ${MAX_CONSECUTIVE_STREAM_ERRORS} consecutive errors with the job possibly still running. ${RESUME_ADVICE}`
|
|
2909
|
+
};
|
|
2910
|
+
}
|
|
2911
|
+
async function collectJobStream(deps) {
|
|
2912
|
+
const { poll, budgetMs, holdMs } = deps;
|
|
2913
|
+
const startedAt = Date.now();
|
|
2914
|
+
const elapsed = () => Date.now() - startedAt;
|
|
2915
|
+
const chunks = [];
|
|
2916
|
+
let result = {};
|
|
2917
|
+
let consecutiveErrors = 0;
|
|
2918
|
+
let lastError;
|
|
2919
|
+
while (true) {
|
|
2920
|
+
try {
|
|
2921
|
+
const reply = await poll(
|
|
2922
|
+
streamPollTimeoutMs(budgetMs - elapsed(), holdMs)
|
|
2923
|
+
);
|
|
2924
|
+
consecutiveErrors = 0;
|
|
2925
|
+
lastError = void 0;
|
|
2926
|
+
if (Array.isArray(reply.stream)) chunks.push(...reply.stream);
|
|
2927
|
+
result = reply;
|
|
2928
|
+
if (TERMINAL_STATUSES.has(reply.status)) break;
|
|
2929
|
+
} catch (error) {
|
|
2930
|
+
consecutiveErrors++;
|
|
2931
|
+
lastError = error instanceof Error ? error.message : String(error);
|
|
2932
|
+
if (consecutiveErrors >= MAX_CONSECUTIVE_STREAM_ERRORS) {
|
|
2933
|
+
result = { ...result, ...errorCapNote(lastError) };
|
|
2934
|
+
break;
|
|
2935
|
+
}
|
|
2936
|
+
}
|
|
2937
|
+
if (elapsed() > budgetMs) {
|
|
2938
|
+
result = { ...result, ...budgetExhaustedNote(budgetMs, lastError) };
|
|
2939
|
+
break;
|
|
2940
|
+
}
|
|
2941
|
+
await sleep(STREAM_JOB_POLL_INTERVAL_MS);
|
|
2942
|
+
}
|
|
2943
|
+
return { result, chunks };
|
|
2944
|
+
}
|
|
2322
2945
|
function registerJobTools(server, rt) {
|
|
2323
2946
|
const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
|
|
2947
|
+
const hosted = rt.transport === "http";
|
|
2948
|
+
const streamBudget = formatBudget(
|
|
2949
|
+
hosted ? HTTP_LONG_POLL_BUDGET_MS : STDIO_STREAM_BUDGET_MS
|
|
2950
|
+
);
|
|
2324
2951
|
async function diagnoseQueuedJob(endpointId) {
|
|
2325
2952
|
const cached = diagnosisCache.get(endpointId);
|
|
2326
2953
|
if (cached && cached.expiresAt > Date.now()) {
|
|
@@ -2331,7 +2958,10 @@ function registerJobTools(server, rt) {
|
|
|
2331
2958
|
const backend = backendFor("workers");
|
|
2332
2959
|
if (backend.version !== "v2") return null;
|
|
2333
2960
|
const raw = await callRestUrl(
|
|
2334
|
-
`${backend.base}/serverless/${endpointId}/workers
|
|
2961
|
+
`${backend.base}/serverless/${endpointId}/workers`,
|
|
2962
|
+
"GET",
|
|
2963
|
+
void 0,
|
|
2964
|
+
{ timeoutMs: QUEUED_DIAGNOSIS_TIMEOUT_MS }
|
|
2335
2965
|
);
|
|
2336
2966
|
const summary = raw?.summary;
|
|
2337
2967
|
const workers = raw?.workers ?? [];
|
|
@@ -2422,14 +3052,14 @@ function registerJobTools(server, rt) {
|
|
|
2422
3052
|
);
|
|
2423
3053
|
server.tool(
|
|
2424
3054
|
"runsync-endpoint",
|
|
2425
|
-
|
|
3055
|
+
`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)."}`,
|
|
2426
3056
|
{
|
|
2427
3057
|
endpointId: endpointIdSchema.describe(
|
|
2428
3058
|
"ID of the Serverless endpoint to run synchronously"
|
|
2429
3059
|
),
|
|
2430
3060
|
input: inputSchema,
|
|
2431
3061
|
wait: import_zod9.z.number().min(1e3).max(3e5).optional().describe(
|
|
2432
|
-
"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)."
|
|
3062
|
+
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)."
|
|
2433
3063
|
),
|
|
2434
3064
|
webhook: webhookSchema,
|
|
2435
3065
|
policy: policySchema,
|
|
@@ -2438,13 +3068,36 @@ function registerJobTools(server, rt) {
|
|
|
2438
3068
|
{ title: "Run endpoint (sync)", ...WRITE },
|
|
2439
3069
|
async (params) => {
|
|
2440
3070
|
const { endpointId, wait, ...body } = params;
|
|
2441
|
-
const
|
|
3071
|
+
const effectiveWait = hosted ? Math.min(
|
|
3072
|
+
wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS,
|
|
3073
|
+
HTTP_LONG_POLL_BUDGET_MS
|
|
3074
|
+
) : wait;
|
|
3075
|
+
const waitQuery = effectiveWait === void 0 ? "" : `?wait=${effectiveWait}`;
|
|
3076
|
+
const path = `/runsync${waitQuery}`;
|
|
2442
3077
|
const result = await serverlessRequest(
|
|
2443
3078
|
endpointId,
|
|
2444
3079
|
path,
|
|
2445
3080
|
"POST",
|
|
2446
|
-
body
|
|
3081
|
+
body,
|
|
3082
|
+
{
|
|
3083
|
+
// From the wait actually sent — on http the clamped 45s, not the
|
|
3084
|
+
// 300s the caller may have asked for.
|
|
3085
|
+
timeoutMs: (effectiveWait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS) + UPSTREAM_HOLD_SLACK_MS
|
|
3086
|
+
}
|
|
2447
3087
|
);
|
|
3088
|
+
const requestedWait = wait ?? RUNSYNC_UPSTREAM_DEFAULT_WAIT_MS;
|
|
3089
|
+
if (hosted && requestedWait > HTTP_LONG_POLL_BUDGET_MS && isUnfinishedJobReply(result)) {
|
|
3090
|
+
return jsonReply2({
|
|
3091
|
+
...result,
|
|
3092
|
+
waitClamped: {
|
|
3093
|
+
requestedMs: requestedWait,
|
|
3094
|
+
effectiveMs: effectiveWait,
|
|
3095
|
+
reason: `This server runs behind a 60-second gateway deadline, so a non-terminal status here means the job outlived ${formatBudget(
|
|
3096
|
+
HTTP_LONG_POLL_BUDGET_MS
|
|
3097
|
+
)}, not the requested wait \u2014 poll get-job-status rather than treating it as stuck.`
|
|
3098
|
+
}
|
|
3099
|
+
});
|
|
3100
|
+
}
|
|
2448
3101
|
return jsonReply2(result);
|
|
2449
3102
|
}
|
|
2450
3103
|
);
|
|
@@ -2478,7 +3131,7 @@ function registerJobTools(server, rt) {
|
|
|
2478
3131
|
);
|
|
2479
3132
|
server.tool(
|
|
2480
3133
|
"stream-job",
|
|
2481
|
-
|
|
3134
|
+
`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." : ""}`,
|
|
2482
3135
|
{
|
|
2483
3136
|
endpointId: endpointIdSchema.describe(
|
|
2484
3137
|
"ID of the Serverless endpoint the job belongs to"
|
|
@@ -2487,51 +3140,16 @@ function registerJobTools(server, rt) {
|
|
|
2487
3140
|
},
|
|
2488
3141
|
{ title: "Stream job", ...READ_ONLY },
|
|
2489
3142
|
async (params) => {
|
|
2490
|
-
const
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
let finalResult = {};
|
|
2501
|
-
let consecutiveErrors = 0;
|
|
2502
|
-
let lastError;
|
|
2503
|
-
const startTime = Date.now();
|
|
2504
|
-
while (true) {
|
|
2505
|
-
try {
|
|
2506
|
-
const result = await serverlessRequest(
|
|
2507
|
-
params.endpointId,
|
|
2508
|
-
`/stream/${params.jobId}`
|
|
2509
|
-
);
|
|
2510
|
-
consecutiveErrors = 0;
|
|
2511
|
-
if (Array.isArray(result.stream)) {
|
|
2512
|
-
allChunks.push(...result.stream);
|
|
2513
|
-
}
|
|
2514
|
-
finalResult = result;
|
|
2515
|
-
if (TERMINAL_STATUSES.has(result.status)) {
|
|
2516
|
-
break;
|
|
2517
|
-
}
|
|
2518
|
-
} catch (error) {
|
|
2519
|
-
consecutiveErrors++;
|
|
2520
|
-
lastError = error instanceof Error ? error.message : String(error);
|
|
2521
|
-
if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) {
|
|
2522
|
-
finalResult.error = `Polling aborted after ${MAX_CONSECUTIVE_ERRORS} consecutive errors: ${lastError}`;
|
|
2523
|
-
break;
|
|
2524
|
-
}
|
|
2525
|
-
}
|
|
2526
|
-
if (Date.now() - startTime > MAX_POLL_TIME_MS) {
|
|
2527
|
-
finalResult.pollingTimedOut = true;
|
|
2528
|
-
finalResult.note = "Polling timed out after 5 minutes. Use get-job-status to check the job later.";
|
|
2529
|
-
if (lastError) finalResult.lastError = lastError;
|
|
2530
|
-
break;
|
|
2531
|
-
}
|
|
2532
|
-
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
2533
|
-
}
|
|
2534
|
-
return jsonReply2({ ...finalResult, stream: allChunks });
|
|
3143
|
+
const plan = streamPollPlan(hosted);
|
|
3144
|
+
const streamPath = `/stream/${params.jobId}${plan.query}`;
|
|
3145
|
+
const { result, chunks } = await collectJobStream({
|
|
3146
|
+
budgetMs: plan.budgetMs,
|
|
3147
|
+
holdMs: plan.holdMs,
|
|
3148
|
+
poll: (timeoutMs) => serverlessRequest(params.endpointId, streamPath, "GET", void 0, {
|
|
3149
|
+
timeoutMs
|
|
3150
|
+
})
|
|
3151
|
+
});
|
|
3152
|
+
return jsonReply2({ ...result, stream: chunks });
|
|
2535
3153
|
}
|
|
2536
3154
|
);
|
|
2537
3155
|
server.tool(
|
|
@@ -3212,10 +3830,14 @@ var defaultCredentialChecker = createCredentialChecker({
|
|
|
3212
3830
|
url: () => authGraphqlUrl(process.env)
|
|
3213
3831
|
});
|
|
3214
3832
|
var sameHost = (a, b) => normalizeUrl(a) === normalizeUrl(b);
|
|
3833
|
+
var HOST_ALIASES = {
|
|
3834
|
+
"v2-rest.runpod.io": "api.runpod.io"
|
|
3835
|
+
};
|
|
3215
3836
|
function normalizeUrl(raw) {
|
|
3216
3837
|
try {
|
|
3217
3838
|
const u = new URL(raw);
|
|
3218
|
-
|
|
3839
|
+
const host = u.host.toLowerCase();
|
|
3840
|
+
return `${u.protocol}//${HOST_ALIASES[host] ?? host}${u.pathname.replace(/\/+$/, "")}`;
|
|
3219
3841
|
} catch {
|
|
3220
3842
|
return raw.trim().toLowerCase().replace(/\/+$/, "");
|
|
3221
3843
|
}
|
|
@@ -3299,6 +3921,23 @@ async function handleMcpRequest(req, res, opts = {}) {
|
|
|
3299
3921
|
url: req.url,
|
|
3300
3922
|
hasBearerToken: !!bearerToken
|
|
3301
3923
|
});
|
|
3924
|
+
if (req.method === "GET") {
|
|
3925
|
+
res.writeHead(405, {
|
|
3926
|
+
Allow: "POST, DELETE",
|
|
3927
|
+
"Content-Type": "application/json"
|
|
3928
|
+
});
|
|
3929
|
+
res.end(
|
|
3930
|
+
JSON.stringify({
|
|
3931
|
+
jsonrpc: "2.0",
|
|
3932
|
+
error: {
|
|
3933
|
+
code: -32e3,
|
|
3934
|
+
message: "Method Not Allowed: this server does not offer a standalone SSE stream."
|
|
3935
|
+
},
|
|
3936
|
+
id: null
|
|
3937
|
+
})
|
|
3938
|
+
);
|
|
3939
|
+
return;
|
|
3940
|
+
}
|
|
3302
3941
|
if (!bearerToken) {
|
|
3303
3942
|
writeUnauthorized(
|
|
3304
3943
|
req,
|