@runpod/mcp-server 3.0.0 → 3.2.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 +42 -0
- package/README.md +59 -319
- package/dist/{chunk-GTBCNGHE.mjs → chunk-WGK7D2YR.mjs} +551 -45
- package/dist/chunk-WGK7D2YR.mjs.map +1 -0
- package/dist/http.cjs +573 -53
- package/dist/http.cjs.map +1 -1
- package/dist/http.mjs +24 -10
- package/dist/http.mjs.map +1 -1
- package/dist/stdio.cjs +568 -52
- package/dist/stdio.cjs.map +1 -1
- package/dist/stdio.mjs +568 -52
- package/dist/stdio.mjs.map +1 -1
- package/dist/tools.cjs +550 -44
- package/dist/tools.cjs.map +1 -1
- package/dist/tools.d.cts +3 -0
- package/dist/tools.d.ts +3 -0
- package/dist/tools.mjs +1 -1
- package/package.json +1 -1
- package/dist/chunk-GTBCNGHE.mjs.map +0 -1
package/dist/http.cjs
CHANGED
|
@@ -99,15 +99,27 @@ function mapPodUpdateToV2(params) {
|
|
|
99
99
|
...compact({ name: params.name })
|
|
100
100
|
};
|
|
101
101
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
102
|
+
var DEFAULT_ENDPOINT_TYPE = "QUEUE";
|
|
103
|
+
var DEFAULT_SCALER_VALUE = 4;
|
|
104
|
+
function defaultScalerType(endpointType) {
|
|
105
|
+
return endpointType === "LOAD_BALANCER" ? "REQUEST_COUNT" : "QUEUE_DELAY";
|
|
106
|
+
}
|
|
107
|
+
function endpointScaling(scalerType, value) {
|
|
108
|
+
return scalerType === "REQUEST_COUNT" ? { type: "REQUEST_COUNT", requestCount: value } : { type: "QUEUE_DELAY", queueDelay: value };
|
|
105
109
|
}
|
|
106
110
|
function endpointGpuConfig(pools, count) {
|
|
107
111
|
if (!pools?.length) return void 0;
|
|
108
112
|
return compact({ pools, count });
|
|
109
113
|
}
|
|
110
|
-
function
|
|
114
|
+
function endpointWorkers(params) {
|
|
115
|
+
const workers = compact({
|
|
116
|
+
min: params.workersMin,
|
|
117
|
+
max: params.workersMax,
|
|
118
|
+
idleTimeout: params.idleTimeout
|
|
119
|
+
});
|
|
120
|
+
return Object.keys(workers).length ? workers : void 0;
|
|
121
|
+
}
|
|
122
|
+
function endpointCommonToV2(params) {
|
|
111
123
|
return compact({
|
|
112
124
|
name: params.name,
|
|
113
125
|
image: params.imageName,
|
|
@@ -117,23 +129,33 @@ function mapEndpointToV2(params) {
|
|
|
117
129
|
env: params.env,
|
|
118
130
|
registry: params.containerRegistryAuthId,
|
|
119
131
|
gpu: endpointGpuConfig(params.gpuPoolIds, params.gpuCount),
|
|
120
|
-
workers:
|
|
121
|
-
min: params.workersMin,
|
|
122
|
-
max: params.workersMax
|
|
123
|
-
}),
|
|
124
|
-
scaling: nestedOrUndefined({
|
|
125
|
-
type: params.scalerType,
|
|
126
|
-
value: params.scalerValue,
|
|
127
|
-
idleTimeout: params.idleTimeout
|
|
128
|
-
}),
|
|
132
|
+
workers: endpointWorkers(params),
|
|
129
133
|
dataCenterIds: params.dataCenterIds?.length ? params.dataCenterIds : void 0,
|
|
130
134
|
networkVolumes: params.networkVolumeIds?.length ? params.networkVolumeIds : void 0,
|
|
131
135
|
timeout: params.executionTimeoutMs,
|
|
132
136
|
flashboot: params.flashboot
|
|
133
137
|
});
|
|
134
138
|
}
|
|
135
|
-
|
|
136
|
-
|
|
139
|
+
function mapEndpointCreateToV2(params) {
|
|
140
|
+
const endpointType = params.endpointType ?? DEFAULT_ENDPOINT_TYPE;
|
|
141
|
+
return compact({
|
|
142
|
+
...endpointCommonToV2(params),
|
|
143
|
+
type: endpointType,
|
|
144
|
+
scaling: endpointScaling(
|
|
145
|
+
params.scalerType ?? defaultScalerType(endpointType),
|
|
146
|
+
params.scalerValue ?? DEFAULT_SCALER_VALUE
|
|
147
|
+
)
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
function mapEndpointUpdateToV2(params) {
|
|
151
|
+
return compact({
|
|
152
|
+
...endpointCommonToV2(params),
|
|
153
|
+
scaling: params.scalerType ? endpointScaling(
|
|
154
|
+
params.scalerType,
|
|
155
|
+
params.scalerValue ?? DEFAULT_SCALER_VALUE
|
|
156
|
+
) : void 0
|
|
157
|
+
});
|
|
158
|
+
}
|
|
137
159
|
function mapNetworkVolumeCreateToV2(params) {
|
|
138
160
|
return compact({
|
|
139
161
|
name: params.name,
|
|
@@ -217,7 +239,7 @@ function restV1Base(env) {
|
|
|
217
239
|
return env.RUNPOD_REST_API_URL ?? "https://rest.runpod.io/v1";
|
|
218
240
|
}
|
|
219
241
|
function restV2Base(env) {
|
|
220
|
-
return env.RUNPOD_REST_V2_API_URL ?? "https://
|
|
242
|
+
return env.RUNPOD_REST_V2_API_URL ?? "https://api.runpod.io/v2";
|
|
221
243
|
}
|
|
222
244
|
function serverlessBase(env) {
|
|
223
245
|
return env.RUNPOD_SERVERLESS_API_URL ?? "https://api.runpod.ai/v2";
|
|
@@ -389,27 +411,176 @@ function resolveBackend(opts) {
|
|
|
389
411
|
|
|
390
412
|
// src/server.ts
|
|
391
413
|
var SERVER_NAME = "Runpod API Server";
|
|
392
|
-
var SERVER_VERSION = true ? "3.
|
|
414
|
+
var SERVER_VERSION = true ? "3.2.0" : "dev";
|
|
415
|
+
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.
|
|
416
|
+
|
|
417
|
+
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.`;
|
|
393
418
|
function createServer(version = SERVER_VERSION) {
|
|
394
|
-
return new import_mcp.McpServer(
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
419
|
+
return new import_mcp.McpServer(
|
|
420
|
+
{
|
|
421
|
+
name: SERVER_NAME,
|
|
422
|
+
version: `${version} [${restVersionSummary(process.env)}]`
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
// Only advertise capabilities the server implements. It registers
|
|
426
|
+
// tools and no resources — advertising `resources` would invite
|
|
427
|
+
// `resources/list` calls that answer -32601.
|
|
428
|
+
capabilities: {
|
|
429
|
+
tools: {}
|
|
430
|
+
},
|
|
431
|
+
instructions: SERVER_INSTRUCTIONS
|
|
400
432
|
}
|
|
401
|
-
|
|
433
|
+
);
|
|
402
434
|
}
|
|
403
435
|
|
|
404
436
|
// src/tools/runtime.ts
|
|
405
437
|
var import_node_fetch = __toESM(require("node-fetch"), 1);
|
|
406
438
|
var import_node_crypto = require("crypto");
|
|
407
439
|
|
|
440
|
+
// src/_shared/rate-limit.ts
|
|
441
|
+
var GENERIC_RATE_LIMIT_HINT = "rate limited \u2014 back off before retrying, and pace bulk operations";
|
|
442
|
+
var MAX_PLAUSIBLE_WAIT_S = 7 * 86400;
|
|
443
|
+
function remainingRequests(raw) {
|
|
444
|
+
const value = raw?.trim();
|
|
445
|
+
return value && /^\d+$/.test(value) ? Number(value) : null;
|
|
446
|
+
}
|
|
447
|
+
function waitSeconds(raw) {
|
|
448
|
+
const value = raw?.trim();
|
|
449
|
+
if (!value || !/^\d+$/.test(value)) return null;
|
|
450
|
+
const seconds = Number(value);
|
|
451
|
+
return seconds <= MAX_PLAUSIBLE_WAIT_S ? seconds : null;
|
|
452
|
+
}
|
|
453
|
+
var WINDOW_NAME_RE = /^(?:"([\x20-\x21\x23-\x5b\x5d-\x7e]{1,32})"|([A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/-]{0,31}))$/;
|
|
454
|
+
function windowName(raw) {
|
|
455
|
+
const match = WINDOW_NAME_RE.exec(raw.trim());
|
|
456
|
+
return match ? match[1] ?? match[2] : null;
|
|
457
|
+
}
|
|
458
|
+
var QUOTED_VALUE_RE = /^"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"$/;
|
|
459
|
+
var DISPLAY_VALUE_RE = /^%"(?:[\x20-\x21\x23-\x24\x26-\x5b\x5d-\x7e]|\\|%[0-9a-f]{2})*"$/;
|
|
460
|
+
function isWellFormedParam(param) {
|
|
461
|
+
const eq = param.indexOf("=");
|
|
462
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim();
|
|
463
|
+
if (key.includes('"')) return false;
|
|
464
|
+
if (eq === -1) return true;
|
|
465
|
+
const value = param.slice(eq + 1).trim();
|
|
466
|
+
if (value.startsWith('"')) return QUOTED_VALUE_RE.test(value);
|
|
467
|
+
if (value.startsWith('%"')) return DISPLAY_VALUE_RE.test(value);
|
|
468
|
+
return !/[\s"]/.test(value);
|
|
469
|
+
}
|
|
470
|
+
function splitOutsideStrings(input, sep) {
|
|
471
|
+
const parts = [];
|
|
472
|
+
let current = "";
|
|
473
|
+
let inString = false;
|
|
474
|
+
let inDisplay = false;
|
|
475
|
+
let escaped = false;
|
|
476
|
+
let prev = "";
|
|
477
|
+
for (const ch of input) {
|
|
478
|
+
if (escaped) escaped = false;
|
|
479
|
+
else if (inString && !inDisplay && ch === "\\") escaped = true;
|
|
480
|
+
else if (ch === '"') {
|
|
481
|
+
if (!inString) inDisplay = prev === "%";
|
|
482
|
+
inString = !inString;
|
|
483
|
+
} else if (ch === sep && !inString) {
|
|
484
|
+
parts.push(current);
|
|
485
|
+
current = "";
|
|
486
|
+
prev = ch;
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
current += ch;
|
|
490
|
+
prev = ch;
|
|
491
|
+
}
|
|
492
|
+
parts.push(current);
|
|
493
|
+
return parts;
|
|
494
|
+
}
|
|
495
|
+
function readParams(params) {
|
|
496
|
+
const values = /* @__PURE__ */ new Map();
|
|
497
|
+
let unreadable = false;
|
|
498
|
+
let valuelessAt = -1;
|
|
499
|
+
for (const [index, param] of params.entries()) {
|
|
500
|
+
const eq = param.indexOf("=");
|
|
501
|
+
const key = (eq === -1 ? param : param.slice(0, eq)).trim().toLowerCase();
|
|
502
|
+
if (!key) continue;
|
|
503
|
+
if (eq === -1) {
|
|
504
|
+
if (key === "r") unreadable = true;
|
|
505
|
+
if (key === "t") values.delete("t");
|
|
506
|
+
valuelessAt = index;
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
if ((key === "r" || key === "t") && (values.has(key) || valuelessAt !== -1 && index > valuelessAt)) {
|
|
510
|
+
unreadable = true;
|
|
511
|
+
}
|
|
512
|
+
values.set(key, param.slice(eq + 1));
|
|
513
|
+
}
|
|
514
|
+
return { values, unreadable };
|
|
515
|
+
}
|
|
516
|
+
function parseExhaustedWindows(header) {
|
|
517
|
+
if (!header) return { windows: [], dropped: false };
|
|
518
|
+
const windows = [];
|
|
519
|
+
let dropped = false;
|
|
520
|
+
for (const item of splitOutsideStrings(header, ",")) {
|
|
521
|
+
if (item.trim() === "") continue;
|
|
522
|
+
const [rawName, ...params] = splitOutsideStrings(item, ";");
|
|
523
|
+
if (!params.every(isWellFormedParam)) {
|
|
524
|
+
dropped = true;
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
const { values, unreadable } = readParams(params);
|
|
528
|
+
if (unreadable) {
|
|
529
|
+
dropped = true;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const remaining = remainingRequests(values.get("r"));
|
|
533
|
+
if (remaining === null) {
|
|
534
|
+
dropped = true;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
if (remaining !== 0) continue;
|
|
538
|
+
windows.push({
|
|
539
|
+
window: windowName(rawName),
|
|
540
|
+
resetS: waitSeconds(values.get("t"))
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
return { windows, dropped };
|
|
544
|
+
}
|
|
545
|
+
function humanWait(seconds) {
|
|
546
|
+
if (seconds < 120) return `~${seconds}s`;
|
|
547
|
+
if (seconds < 3600)
|
|
548
|
+
return `~${Math.ceil(seconds / 60)} minutes (${seconds}s)`;
|
|
549
|
+
if (seconds < 2 * 86400) {
|
|
550
|
+
const hours = Math.ceil(seconds / 3600);
|
|
551
|
+
return `~${hours} hour${hours === 1 ? "" : "s"} (${seconds}s)`;
|
|
552
|
+
}
|
|
553
|
+
return `~${Math.ceil(seconds / 86400)} days (${seconds}s)`;
|
|
554
|
+
}
|
|
555
|
+
function rateLimitHint(rateLimitHeader, retryAfterHeader) {
|
|
556
|
+
const { windows, dropped } = parseExhaustedWindows(rateLimitHeader);
|
|
557
|
+
windows.sort((a, b) => (b.resetS ?? -1) - (a.resetS ?? -1));
|
|
558
|
+
const longestWindow = windows[0];
|
|
559
|
+
const waitCandidates = [
|
|
560
|
+
waitSeconds(retryAfterHeader),
|
|
561
|
+
longestWindow?.resetS
|
|
562
|
+
].filter((n) => typeof n === "number" && n > 0);
|
|
563
|
+
const waitS = waitCandidates.length ? Math.max(...waitCandidates) : null;
|
|
564
|
+
const retryAfterUnreadable = typeof retryAfterHeader === "string" && retryAfterHeader.trim() !== "" && waitSeconds(retryAfterHeader) === null;
|
|
565
|
+
if (!longestWindow && waitS === null) {
|
|
566
|
+
if (!retryAfterUnreadable) return GENERIC_RATE_LIMIT_HINT;
|
|
567
|
+
}
|
|
568
|
+
const namedClause = longestWindow?.window ? `the ${longestWindow.window} quota is exhausted` : "";
|
|
569
|
+
if (waitS === null) {
|
|
570
|
+
const cause = namedClause ? `${namedClause}, but the response gave no usable reset time` : "the response gave no usable reset time";
|
|
571
|
+
return `rate limited \u2014 ${cause}; back off substantially before retrying, and pace bulk operations`;
|
|
572
|
+
}
|
|
573
|
+
const hedge = dropped || retryAfterUnreadable || windows.some((w) => w.resetS === null) ? "at least " : "";
|
|
574
|
+
const wait = `wait ${hedge}${humanWait(waitS)} before retrying, and pace bulk operations`;
|
|
575
|
+
return `rate limited \u2014 ${namedClause ? `${namedClause}; ` : ""}${wait}`;
|
|
576
|
+
}
|
|
577
|
+
|
|
408
578
|
// src/_shared/http.ts
|
|
579
|
+
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";
|
|
409
580
|
var HttpError = class extends Error {
|
|
410
|
-
constructor(prefix, status, body) {
|
|
411
|
-
const
|
|
412
|
-
super(`${prefix}: ${status} - ${
|
|
581
|
+
constructor(prefix, status, body, hint) {
|
|
582
|
+
const shownBody = body.length > 2048 ? `${body.slice(0, 2048)}\u2026 [truncated ${body.length - 2048} chars]` : body;
|
|
583
|
+
super(`${prefix}: ${status} - ${shownBody}${hint ? ` (${hint})` : ""}`);
|
|
413
584
|
this.name = "HttpError";
|
|
414
585
|
this.status = status;
|
|
415
586
|
this.body = body;
|
|
@@ -444,7 +615,11 @@ function createHttpClient(deps) {
|
|
|
444
615
|
throw new HttpError(
|
|
445
616
|
deps.errorPrefix,
|
|
446
617
|
response.status,
|
|
447
|
-
await response.text()
|
|
618
|
+
await response.text(),
|
|
619
|
+
response.status === 429 ? rateLimitHint(
|
|
620
|
+
response.headers.get("ratelimit"),
|
|
621
|
+
response.headers.get("retry-after")
|
|
622
|
+
) : response.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
448
623
|
);
|
|
449
624
|
}
|
|
450
625
|
return isJsonContentType(response.headers.get("content-type")) ? response.json() : { success: true, status: response.status };
|
|
@@ -503,7 +678,15 @@ async function readSseSnapshot(fetchImpl, url, headers, opts) {
|
|
|
503
678
|
throw new HttpError(
|
|
504
679
|
"Runpod API Error",
|
|
505
680
|
res.status,
|
|
506
|
-
await res.text().catch(() => "")
|
|
681
|
+
await res.text().catch(() => ""),
|
|
682
|
+
// This throw bypasses createHttpClient, so the status hints are built
|
|
683
|
+
// here too or these two tools alone return a bare 429/401. The 401
|
|
684
|
+
// hint applies: this request always carries Authorization (see the
|
|
685
|
+
// headers bound in runtime.ts streamSse).
|
|
686
|
+
res.status === 429 ? rateLimitHint(
|
|
687
|
+
res.headers.get("ratelimit"),
|
|
688
|
+
res.headers.get("retry-after")
|
|
689
|
+
) : res.status === 401 ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
507
690
|
);
|
|
508
691
|
}
|
|
509
692
|
for await (const chunk of res.body ?? []) {
|
|
@@ -566,7 +749,7 @@ async function streamLogsReply(rt, tool, params) {
|
|
|
566
749
|
}
|
|
567
750
|
|
|
568
751
|
// src/tools/runtime.ts
|
|
569
|
-
var MCP_SERVER_VERSION = true ? "3.
|
|
752
|
+
var MCP_SERVER_VERSION = true ? "3.2.0" : "dev";
|
|
570
753
|
var SESSION_ID = (0, import_node_crypto.randomUUID)();
|
|
571
754
|
function trackingHeaders(server, ctx) {
|
|
572
755
|
const info = server.server.getClientVersion();
|
|
@@ -616,6 +799,36 @@ async function graphqlRequest(query, tracking, fetchImpl, url, options) {
|
|
|
616
799
|
...options?.variables ? { variables: options.variables } : {}
|
|
617
800
|
})
|
|
618
801
|
});
|
|
802
|
+
if (!response.ok) {
|
|
803
|
+
const bodyText = await response.text();
|
|
804
|
+
let gqlErrors;
|
|
805
|
+
try {
|
|
806
|
+
gqlErrors = JSON.parse(bodyText).errors;
|
|
807
|
+
} catch {
|
|
808
|
+
}
|
|
809
|
+
if (Array.isArray(gqlErrors) && gqlErrors.length > 0 && response.status !== 401 && response.status !== 429) {
|
|
810
|
+
throw new HttpError(
|
|
811
|
+
"Runpod GraphQL Error",
|
|
812
|
+
response.status,
|
|
813
|
+
gqlErrors.map((e) => String(e?.message ?? JSON.stringify(e))).join(", ")
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
throw new HttpError(
|
|
817
|
+
"Runpod GraphQL Error",
|
|
818
|
+
response.status,
|
|
819
|
+
bodyText,
|
|
820
|
+
response.status === 429 ? rateLimitHint(
|
|
821
|
+
response.headers.get("ratelimit"),
|
|
822
|
+
response.headers.get("retry-after")
|
|
823
|
+
) : (
|
|
824
|
+
// Only when this request actually carried the caller's API key: the
|
|
825
|
+
// public path sends no Authorization header, so a 401 from that
|
|
826
|
+
// host (WAF, misconfigured RUNPOD_PUBLIC_GRAPHQL_URL) says nothing
|
|
827
|
+
// about the credential — same rationale as observeUnauthorized.
|
|
828
|
+
response.status === 401 && options?.apiKey ? EXPIRED_CREDENTIAL_HINT : void 0
|
|
829
|
+
)
|
|
830
|
+
);
|
|
831
|
+
}
|
|
619
832
|
const result = await response.json();
|
|
620
833
|
if (result.errors && result.errors.length > 0) {
|
|
621
834
|
throw new Error(
|
|
@@ -1032,6 +1245,178 @@ function registerCatalogTools(server, rt) {
|
|
|
1032
1245
|
return jsonReply2(result);
|
|
1033
1246
|
}
|
|
1034
1247
|
);
|
|
1248
|
+
server.tool(
|
|
1249
|
+
"get-capacity",
|
|
1250
|
+
"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.",
|
|
1251
|
+
{
|
|
1252
|
+
...listPaginationParams,
|
|
1253
|
+
cudaVersions: import_zod3.z.array(import_zod3.z.string().regex(/^\d{1,2}\.\d{1,2}$/)).min(1).max(12).optional().describe(
|
|
1254
|
+
'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.'
|
|
1255
|
+
),
|
|
1256
|
+
includeUnavailable: import_zod3.z.boolean().optional().describe(
|
|
1257
|
+
'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.'
|
|
1258
|
+
),
|
|
1259
|
+
gpuTypeIds: import_zod3.z.array(import_zod3.z.string()).optional().describe(
|
|
1260
|
+
"Filter to GPU types matching any of these ids or display names (case-insensitive substring, e.g. ['NVIDIA GeForce RTX 4090'] or ['4090', 'H200'])."
|
|
1261
|
+
),
|
|
1262
|
+
gpuCount: import_zod3.z.number().int().min(1).max(8).optional().describe("GPUs per worker/pod to check stock for (default 1)."),
|
|
1263
|
+
secureCloudOnly: import_zod3.z.boolean().optional().describe("Restrict the stock lookup to Secure Cloud hosts.")
|
|
1264
|
+
},
|
|
1265
|
+
{ title: "Get GPU capacity by CUDA version", ...READ_ONLY },
|
|
1266
|
+
async (params) => {
|
|
1267
|
+
const gpuCount = Math.min(
|
|
1268
|
+
8,
|
|
1269
|
+
Math.max(1, Math.floor(Number(params.gpuCount)) || 1)
|
|
1270
|
+
);
|
|
1271
|
+
const secureArg = params.secureCloudOnly ? ", secureCloud: true" : "";
|
|
1272
|
+
const filterTerms = (params.gpuTypeIds ?? []).filter((t) => typeof t === "string" && t.trim().length > 0).map((t) => t.toLowerCase());
|
|
1273
|
+
const matchesFilter = (gpu) => {
|
|
1274
|
+
if (filterTerms.length === 0) return true;
|
|
1275
|
+
const id = gpu.id.toLowerCase();
|
|
1276
|
+
const name = gpu.displayName.toLowerCase();
|
|
1277
|
+
return filterTerms.some(
|
|
1278
|
+
(term) => id.includes(term) || name.includes(term)
|
|
1279
|
+
);
|
|
1280
|
+
};
|
|
1281
|
+
const stockPriority = {
|
|
1282
|
+
High: 3,
|
|
1283
|
+
Medium: 2,
|
|
1284
|
+
Low: 1
|
|
1285
|
+
};
|
|
1286
|
+
if (!params.cudaVersions?.length) {
|
|
1287
|
+
const data = await graphql(`
|
|
1288
|
+
query {
|
|
1289
|
+
gpuTypes {
|
|
1290
|
+
id
|
|
1291
|
+
displayName
|
|
1292
|
+
memoryInGb
|
|
1293
|
+
secureCloud
|
|
1294
|
+
communityCloud
|
|
1295
|
+
lowestPrice(input: { gpuCount: ${gpuCount}${secureArg} }) {
|
|
1296
|
+
stockStatus
|
|
1297
|
+
uninterruptablePrice
|
|
1298
|
+
gpuTypeCudaVersions {
|
|
1299
|
+
cudaVersion
|
|
1300
|
+
availability
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
`);
|
|
1306
|
+
let rows2 = data.gpuTypes.filter((gpu) => gpu.id !== "unknown" && matchesFilter(gpu)).map((gpu) => {
|
|
1307
|
+
const cuda = {};
|
|
1308
|
+
for (const c of gpu.lowestPrice?.gpuTypeCudaVersions ?? []) {
|
|
1309
|
+
cuda[c.cudaVersion] = c.availability;
|
|
1310
|
+
}
|
|
1311
|
+
return {
|
|
1312
|
+
id: gpu.id,
|
|
1313
|
+
displayName: gpu.displayName,
|
|
1314
|
+
memoryGb: gpu.memoryInGb,
|
|
1315
|
+
secureCloud: gpu.secureCloud,
|
|
1316
|
+
communityCloud: gpu.communityCloud,
|
|
1317
|
+
stockStatus: gpu.lowestPrice?.stockStatus || "unavailable",
|
|
1318
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null,
|
|
1319
|
+
cudaVersions: cuda
|
|
1320
|
+
};
|
|
1321
|
+
});
|
|
1322
|
+
const availableCount = (r) => Object.values(r.cudaVersions).filter((a) => a === "AVAILABLE").length;
|
|
1323
|
+
if (params.includeUnavailable === false)
|
|
1324
|
+
rows2 = rows2.filter((r) => availableCount(r) > 0);
|
|
1325
|
+
rows2.sort((a, b) => {
|
|
1326
|
+
const diff = availableCount(b) - availableCount(a);
|
|
1327
|
+
if (diff !== 0) return diff;
|
|
1328
|
+
const stockDiff = (stockPriority[b.stockStatus] || 0) - (stockPriority[a.stockStatus] || 0);
|
|
1329
|
+
if (stockDiff !== 0) return stockDiff;
|
|
1330
|
+
return b.memoryGb - a.memoryGb;
|
|
1331
|
+
});
|
|
1332
|
+
return capListResult(rows2, {
|
|
1333
|
+
limit: params.limit,
|
|
1334
|
+
cursor: params.cursor
|
|
1335
|
+
});
|
|
1336
|
+
}
|
|
1337
|
+
const versions = [...new Set(params.cudaVersions)].slice(0, 12);
|
|
1338
|
+
const invalid = versions.filter((v) => !/^\d{1,2}\.\d{1,2}$/.test(v));
|
|
1339
|
+
if (invalid.length > 0) {
|
|
1340
|
+
return jsonReply2({
|
|
1341
|
+
error: `Invalid CUDA version format: ${invalid.join(", ")}. Use "major.minor" strings like "12.8".`,
|
|
1342
|
+
status: 400
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
const perVersion = await Promise.allSettled(
|
|
1346
|
+
versions.map(
|
|
1347
|
+
(v) => graphql(`
|
|
1348
|
+
query {
|
|
1349
|
+
gpuTypes {
|
|
1350
|
+
id
|
|
1351
|
+
displayName
|
|
1352
|
+
memoryInGb
|
|
1353
|
+
secureCloud
|
|
1354
|
+
communityCloud
|
|
1355
|
+
lowestPrice(input: { gpuCount: ${gpuCount}, allowedCudaVersions: ["${v}"]${secureArg} }) {
|
|
1356
|
+
stockStatus
|
|
1357
|
+
uninterruptablePrice
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
`)
|
|
1362
|
+
)
|
|
1363
|
+
);
|
|
1364
|
+
const probeErrors = {};
|
|
1365
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1366
|
+
versions.forEach((v, i) => {
|
|
1367
|
+
const settled = perVersion[i];
|
|
1368
|
+
if (settled.status === "rejected") {
|
|
1369
|
+
probeErrors[v] = settled.reason instanceof Error ? settled.reason.message : String(settled.reason);
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1372
|
+
for (const gpu of settled.value.gpuTypes) {
|
|
1373
|
+
if (gpu.id === "unknown" || !matchesFilter(gpu)) continue;
|
|
1374
|
+
let row = byId.get(gpu.id);
|
|
1375
|
+
if (!row) {
|
|
1376
|
+
row = {
|
|
1377
|
+
id: gpu.id,
|
|
1378
|
+
displayName: gpu.displayName,
|
|
1379
|
+
memoryGb: gpu.memoryInGb,
|
|
1380
|
+
secureCloud: gpu.secureCloud,
|
|
1381
|
+
communityCloud: gpu.communityCloud,
|
|
1382
|
+
cudaVersions: {}
|
|
1383
|
+
};
|
|
1384
|
+
byId.set(gpu.id, row);
|
|
1385
|
+
}
|
|
1386
|
+
const stock = gpu.lowestPrice?.stockStatus;
|
|
1387
|
+
row.cudaVersions[v] = !stock || stock === "Out" ? { stock: "Out", pricePerHr: null } : {
|
|
1388
|
+
stock,
|
|
1389
|
+
pricePerHr: gpu.lowestPrice?.uninterruptablePrice ?? null
|
|
1390
|
+
};
|
|
1391
|
+
}
|
|
1392
|
+
});
|
|
1393
|
+
let rows = [...byId.values()];
|
|
1394
|
+
const inStockCount = (r) => Object.values(r.cudaVersions).filter((c) => c.stock !== "Out").length;
|
|
1395
|
+
if (params.includeUnavailable === false)
|
|
1396
|
+
rows = rows.filter((r) => inStockCount(r) > 0);
|
|
1397
|
+
rows.sort((a, b) => {
|
|
1398
|
+
const diff = inStockCount(b) - inStockCount(a);
|
|
1399
|
+
if (diff !== 0) return diff;
|
|
1400
|
+
const best = (r) => Math.max(
|
|
1401
|
+
0,
|
|
1402
|
+
...Object.values(r.cudaVersions).map(
|
|
1403
|
+
(c) => stockPriority[c.stock] || 0
|
|
1404
|
+
)
|
|
1405
|
+
);
|
|
1406
|
+
const bestDiff = best(b) - best(a);
|
|
1407
|
+
if (bestDiff !== 0) return bestDiff;
|
|
1408
|
+
return b.memoryGb - a.memoryGb;
|
|
1409
|
+
});
|
|
1410
|
+
return capListResult(
|
|
1411
|
+
rows,
|
|
1412
|
+
{ limit: params.limit, cursor: params.cursor },
|
|
1413
|
+
{
|
|
1414
|
+
probedCudaVersions: versions,
|
|
1415
|
+
...Object.keys(probeErrors).length > 0 ? { probeErrors } : {}
|
|
1416
|
+
}
|
|
1417
|
+
);
|
|
1418
|
+
}
|
|
1419
|
+
);
|
|
1035
1420
|
server.tool(
|
|
1036
1421
|
"get-data-center",
|
|
1037
1422
|
"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).",
|
|
@@ -1716,7 +2101,9 @@ function registerPodTools(server, rt) {
|
|
|
1716
2101
|
"create-pod",
|
|
1717
2102
|
'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.',
|
|
1718
2103
|
{
|
|
1719
|
-
name: import_zod7.z.string().optional().describe(
|
|
2104
|
+
name: import_zod7.z.string().optional().describe(
|
|
2105
|
+
"Name for the pod. Required on v2 unless deploying from a templateId (the template supplies its name as the default)."
|
|
2106
|
+
),
|
|
1720
2107
|
imageName: import_zod7.z.string().optional().describe("Docker image to use. Optional when templateId is provided."),
|
|
1721
2108
|
templateId: import_zod7.z.string().optional().describe(
|
|
1722
2109
|
"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."
|
|
@@ -1775,6 +2162,12 @@ function registerPodTools(server, rt) {
|
|
|
1775
2162
|
status: 400
|
|
1776
2163
|
});
|
|
1777
2164
|
}
|
|
2165
|
+
if (!wantsCpu && !params.name && !params.templateId) {
|
|
2166
|
+
return jsonReply2({
|
|
2167
|
+
error: "Provide a name for the pod (deploying from a templateId inherits the template name).",
|
|
2168
|
+
status: 400
|
|
2169
|
+
});
|
|
2170
|
+
}
|
|
1778
2171
|
if (wantsCpu) {
|
|
1779
2172
|
if (params.templateId) {
|
|
1780
2173
|
return jsonReply2({
|
|
@@ -1952,11 +2345,22 @@ function registerPodTools(server, rt) {
|
|
|
1952
2345
|
|
|
1953
2346
|
// src/tools/endpoints.ts
|
|
1954
2347
|
var import_zod8 = require("zod");
|
|
2348
|
+
var MIN_QUEUE_DELAY = 0.5;
|
|
2349
|
+
var MIN_REQUEST_COUNT = 1;
|
|
2350
|
+
var MIN_IDLE_TIMEOUT = 1;
|
|
2351
|
+
var MAX_IDLE_TIMEOUT = 3600;
|
|
2352
|
+
function scalerValueError(scalerType, scalerValue) {
|
|
2353
|
+
if (scalerType !== "REQUEST_COUNT" || scalerValue === void 0) return;
|
|
2354
|
+
if (!Number.isInteger(scalerValue) || scalerValue < MIN_REQUEST_COUNT) {
|
|
2355
|
+
return `REQUEST_COUNT scales on whole in-flight requests per worker, so scalerValue must be an integer >= ${MIN_REQUEST_COUNT} (got ${scalerValue}). For a fractional target use scalerType QUEUE_DELAY, which takes seconds >= ${MIN_QUEUE_DELAY}.`;
|
|
2356
|
+
}
|
|
2357
|
+
return;
|
|
2358
|
+
}
|
|
1955
2359
|
function registerEndpointTools(server, rt) {
|
|
1956
2360
|
const { jsonReply: jsonReply2, callRestUrl, backendFor } = rt;
|
|
1957
2361
|
server.tool(
|
|
1958
2362
|
"list-endpoints",
|
|
1959
|
-
"List your Serverless endpoints, optionally expanding template and worker details (v1 only). Paginated via limit/cursor.",
|
|
2363
|
+
"List your Serverless endpoints, optionally expanding template and worker details (v1 only). Paginated via limit/cursor. On v2 each endpoint carries its request routing in `type` and the URLs to call it in `requestUrls`.",
|
|
1960
2364
|
{
|
|
1961
2365
|
...listPaginationParams,
|
|
1962
2366
|
includeTemplate: import_zod8.z.boolean().optional().describe("Include template information (v1 only)"),
|
|
@@ -1991,7 +2395,7 @@ function registerEndpointTools(server, rt) {
|
|
|
1991
2395
|
);
|
|
1992
2396
|
server.tool(
|
|
1993
2397
|
"get-endpoint",
|
|
1994
|
-
"Get one Serverless endpoint by id, optionally expanding template and worker details (v1 only).",
|
|
2398
|
+
"Get one Serverless endpoint by id, optionally expanding template and worker details (v1 only). On v2 the reply carries `type` (queue-based vs load-balancing routing) and `requestUrls` \u2014 the run/runsync/status/... URLs for a queue endpoint, or the base + health URLs for a load-balancing one. Read those rather than constructing endpoint URLs by hand.",
|
|
1995
2399
|
{
|
|
1996
2400
|
endpointId: import_zod8.z.string().describe("ID of the endpoint to retrieve"),
|
|
1997
2401
|
includeTemplate: import_zod8.z.boolean().optional().describe("Include template information (v1 only)"),
|
|
@@ -2023,11 +2427,14 @@ function registerEndpointTools(server, rt) {
|
|
|
2023
2427
|
);
|
|
2024
2428
|
server.tool(
|
|
2025
2429
|
"create-endpoint",
|
|
2026
|
-
"Create a Serverless endpoint. On v2 (default), pass an inline config: imageName + gpuPoolIds (GPU pool names from list-gpu-types \u2014 the `pool` field, e.g. AMPERE_80/ADA_24) plus optional workers/scaling/disk/env. On v1, pass a templateId instead. Worker min/max set autoscaling bounds (min 0 = scale to zero).",
|
|
2430
|
+
"Create a Serverless endpoint. On v2 (default), pass an inline config: imageName + gpuPoolIds (GPU pool names from list-gpu-types \u2014 the `pool` field, e.g. AMPERE_80/ADA_24) plus optional workers/scaling/disk/env. On v1, pass a templateId instead. Worker min/max set autoscaling bounds (min 0 = scale to zero). Use endpointType to pick queue-based (default) or load-balancing request routing; the response `requestUrls` carries the URLs to call the endpoint with.",
|
|
2027
2431
|
{
|
|
2028
2432
|
name: import_zod8.z.string().optional().describe("Name for the endpoint"),
|
|
2029
2433
|
// --- v2 inline-config fields ---
|
|
2030
2434
|
imageName: import_zod8.z.string().optional().describe("Docker image (v2). Required on v2 instead of a templateId."),
|
|
2435
|
+
endpointType: import_zod8.z.enum(["QUEUE", "LOAD_BALANCER"]).optional().describe(
|
|
2436
|
+
"How requests reach the workers (v2). QUEUE (default) = submit jobs through the managed queue (run/runsync/status/...). LOAD_BALANCER = send HTTP requests straight to worker-defined paths, and forces scalerType REQUEST_COUNT. Fixed at creation \u2014 update-endpoint cannot change it."
|
|
2437
|
+
),
|
|
2031
2438
|
gpuPoolIds: import_zod8.z.array(import_zod8.z.string()).optional().describe(
|
|
2032
2439
|
'GPU pool names (v2, required). The `pool` field from list-gpu-types, e.g. ["AMPERE_80"]. NOT GPU type ids.'
|
|
2033
2440
|
),
|
|
@@ -2047,9 +2454,15 @@ function registerEndpointTools(server, rt) {
|
|
|
2047
2454
|
// --- shared autoscaling fields ---
|
|
2048
2455
|
workersMin: import_zod8.z.number().optional().describe("Minimum number of workers"),
|
|
2049
2456
|
workersMax: import_zod8.z.number().optional().describe("Maximum number of workers"),
|
|
2050
|
-
scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe(
|
|
2051
|
-
|
|
2052
|
-
|
|
2457
|
+
scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe(
|
|
2458
|
+
"Autoscaler signal. QUEUE_DELAY scales on how long requests wait in the queue (queue endpoints only); REQUEST_COUNT scales on in-flight requests per worker. Defaults to QUEUE_DELAY for QUEUE endpoints and REQUEST_COUNT for LOAD_BALANCER ones."
|
|
2459
|
+
),
|
|
2460
|
+
scalerValue: import_zod8.z.number().min(MIN_QUEUE_DELAY).optional().describe(
|
|
2461
|
+
"Autoscaler target for the chosen scalerType \u2014 seconds of queue delay (min 0.5) or in-flight requests per worker (integer, min 1). Defaults to 4."
|
|
2462
|
+
),
|
|
2463
|
+
idleTimeout: import_zod8.z.number().int().min(MIN_IDLE_TIMEOUT).max(MAX_IDLE_TIMEOUT).optional().describe(
|
|
2464
|
+
"Idle timeout in seconds before scaling a worker down (1-3600). Does not apply to QUEUE endpoints scaling on REQUEST_COUNT."
|
|
2465
|
+
),
|
|
2053
2466
|
dataCenterIds: import_zod8.z.array(import_zod8.z.string()).optional().describe("List of preferred data centers")
|
|
2054
2467
|
},
|
|
2055
2468
|
{ title: "Create endpoint", ...WRITE },
|
|
@@ -2074,6 +2487,19 @@ function registerEndpointTools(server, rt) {
|
|
|
2074
2487
|
status: 400
|
|
2075
2488
|
});
|
|
2076
2489
|
}
|
|
2490
|
+
if (params.endpointType === "LOAD_BALANCER" && params.scalerType === "QUEUE_DELAY") {
|
|
2491
|
+
return jsonReply2({
|
|
2492
|
+
error: "LOAD_BALANCER endpoints have no request queue, so they cannot scale on QUEUE_DELAY. Use scalerType REQUEST_COUNT (the default for this endpoint type), or create a QUEUE endpoint instead.",
|
|
2493
|
+
status: 400
|
|
2494
|
+
});
|
|
2495
|
+
}
|
|
2496
|
+
const scalerError = scalerValueError(
|
|
2497
|
+
params.scalerType ?? (params.endpointType === "LOAD_BALANCER" ? "REQUEST_COUNT" : void 0),
|
|
2498
|
+
params.scalerValue
|
|
2499
|
+
);
|
|
2500
|
+
if (scalerError) {
|
|
2501
|
+
return jsonReply2({ error: scalerError, status: 400 });
|
|
2502
|
+
}
|
|
2077
2503
|
const body = backend.mapCreate(params);
|
|
2078
2504
|
const result2 = await callRestUrl(
|
|
2079
2505
|
`${backend.base}${backend.list}`,
|
|
@@ -2111,15 +2537,21 @@ function registerEndpointTools(server, rt) {
|
|
|
2111
2537
|
);
|
|
2112
2538
|
server.tool(
|
|
2113
2539
|
"update-endpoint",
|
|
2114
|
-
"Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change.",
|
|
2540
|
+
"Update a Serverless endpoint's config. On v2 you can change image/disk/env/ports/registry/workers/scaling/networkVolumes/timeout/flashboot; on v1, scaling fields (worker min/max, idle timeout, scaler type/value, name). Only provided fields change. An endpoint's request routing (queue vs load balancer) is fixed at creation and cannot be changed here \u2014 recreate the endpoint instead.",
|
|
2115
2541
|
{
|
|
2116
2542
|
endpointId: import_zod8.z.string().describe("ID of the endpoint to update"),
|
|
2117
2543
|
name: import_zod8.z.string().optional().describe("New name for the endpoint"),
|
|
2118
2544
|
workersMin: import_zod8.z.number().optional().describe("New minimum number of workers"),
|
|
2119
2545
|
workersMax: import_zod8.z.number().optional().describe("New maximum number of workers"),
|
|
2120
|
-
idleTimeout: import_zod8.z.number().optional().describe(
|
|
2121
|
-
|
|
2122
|
-
|
|
2546
|
+
idleTimeout: import_zod8.z.number().int().min(MIN_IDLE_TIMEOUT).max(MAX_IDLE_TIMEOUT).optional().describe(
|
|
2547
|
+
"New idle timeout in seconds (1-3600). Does not apply to queue endpoints scaling on REQUEST_COUNT."
|
|
2548
|
+
),
|
|
2549
|
+
scalerType: import_zod8.z.enum(["QUEUE_DELAY", "REQUEST_COUNT"]).optional().describe(
|
|
2550
|
+
"New autoscaler signal. Switchable on queue endpoints; load-balancing endpoints only accept REQUEST_COUNT."
|
|
2551
|
+
),
|
|
2552
|
+
scalerValue: import_zod8.z.number().min(MIN_QUEUE_DELAY).optional().describe(
|
|
2553
|
+
"New autoscaler target \u2014 seconds of queue delay (min 0.5) or in-flight requests per worker (integer, min 1). Applies to the endpoint's current scalerType unless you also pass a new one."
|
|
2554
|
+
),
|
|
2123
2555
|
// --- v2-only inline-config fields ---
|
|
2124
2556
|
imageName: import_zod8.z.string().optional().describe("New Docker image (v2)"),
|
|
2125
2557
|
gpuPoolIds: import_zod8.z.array(import_zod8.z.string()).optional().describe('New GPU pool names (v2), e.g. ["AMPERE_80"]'),
|
|
@@ -2137,13 +2569,28 @@ function registerEndpointTools(server, rt) {
|
|
|
2137
2569
|
async (params) => {
|
|
2138
2570
|
const { endpointId, ...updateParams } = params;
|
|
2139
2571
|
const backend = backendFor("endpoints");
|
|
2140
|
-
const
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
"PATCH",
|
|
2144
|
-
|
|
2572
|
+
const url = `${backend.base}${backend.get(endpointId)}`;
|
|
2573
|
+
if (backend.version !== "v2") {
|
|
2574
|
+
const body2 = backend.mapUpdate(updateParams);
|
|
2575
|
+
return jsonReply2(await callRestUrl(url, "PATCH", body2));
|
|
2576
|
+
}
|
|
2577
|
+
let scalerType = updateParams.scalerType;
|
|
2578
|
+
if (scalerType === void 0 && updateParams.scalerValue !== void 0) {
|
|
2579
|
+
const current = await callRestUrl(url);
|
|
2580
|
+
scalerType = current?.scaling?.type === "REQUEST_COUNT" ? "REQUEST_COUNT" : "QUEUE_DELAY";
|
|
2581
|
+
}
|
|
2582
|
+
const scalerError = scalerValueError(
|
|
2583
|
+
scalerType,
|
|
2584
|
+
updateParams.scalerValue
|
|
2145
2585
|
);
|
|
2146
|
-
|
|
2586
|
+
if (scalerError) {
|
|
2587
|
+
return jsonReply2({ error: scalerError, status: 400 });
|
|
2588
|
+
}
|
|
2589
|
+
const body = backend.mapUpdate({
|
|
2590
|
+
...updateParams,
|
|
2591
|
+
scalerType
|
|
2592
|
+
});
|
|
2593
|
+
return jsonReply2(await callRestUrl(url, "PATCH", body));
|
|
2147
2594
|
}
|
|
2148
2595
|
);
|
|
2149
2596
|
server.tool(
|
|
@@ -2191,7 +2638,7 @@ function registerEndpointTools(server, rt) {
|
|
|
2191
2638
|
);
|
|
2192
2639
|
server.tool(
|
|
2193
2640
|
"list-endpoint-workers",
|
|
2194
|
-
"List the workers backing a Serverless endpoint, with their status and an aggregate summary. v2-only \u2014 returns a 501 notice on the v1 API. Paginated via limit/cursor.",
|
|
2641
|
+
"List the workers backing a Serverless endpoint, with their status and an aggregate summary. v2-only \u2014 returns a 501 notice on the v1 API. An UNHEALTHY worker usually means the container is crash-looping (repeated starts that exit before the handler runs) \u2014 jobs on the endpoint will sit IN_QUEUE even though this is a worker failure, not a capacity shortage; inspect it with stream-worker-logs. Zero workers while jobs queue means the endpoint is waiting for GPU capacity. Paginated via limit/cursor.",
|
|
2195
2642
|
{
|
|
2196
2643
|
...listPaginationParams,
|
|
2197
2644
|
endpointId: import_zod8.z.string().describe("ID of the Serverless endpoint whose workers to list")
|
|
@@ -2217,7 +2664,7 @@ function registerEndpointTools(server, rt) {
|
|
|
2217
2664
|
);
|
|
2218
2665
|
server.tool(
|
|
2219
2666
|
"stream-worker-logs",
|
|
2220
|
-
"Fetch a bounded snapshot of a serverless worker's live logs (container and/or system) via Server-Sent Events. v2-only \u2014 returns a 501 notice on the v1 API. Get the workerId from list-endpoint-workers. Reads for up to maxWaitMs (default 5s) and returns the collected log lines; use `tail` to backfill recent lines first. Large output is truncated (see the `truncated` flag).",
|
|
2667
|
+
"Fetch a bounded snapshot of a serverless worker's live logs (container and/or system) via Server-Sent Events. v2-only \u2014 returns a 501 notice on the v1 API. Get the workerId from list-endpoint-workers. Use this to diagnose UNHEALTHY workers: repeated `start container` system lines with no container output is the crash-loop signature (the container exits before the handler runs). Reads for up to maxWaitMs (default 5s) and returns the collected log lines; use `tail` to backfill recent lines first. Large output is truncated (see the `truncated` flag).",
|
|
2221
2668
|
{
|
|
2222
2669
|
endpointId: import_zod8.z.string().describe("ID of the Serverless endpoint the worker belongs to"),
|
|
2223
2670
|
workerId: import_zod8.z.string().describe(
|
|
@@ -2240,8 +2687,67 @@ function registerEndpointTools(server, rt) {
|
|
|
2240
2687
|
|
|
2241
2688
|
// src/tools/jobs.ts
|
|
2242
2689
|
var import_zod9 = require("zod");
|
|
2690
|
+
var DIAGNOSIS_TTL_MS = 15e3;
|
|
2691
|
+
var MAX_DIAGNOSIS_ENTRIES = 500;
|
|
2692
|
+
var diagnosisCache = /* @__PURE__ */ new Map();
|
|
2243
2693
|
function registerJobTools(server, rt) {
|
|
2244
|
-
const { jsonReply: jsonReply2, serverlessRequest } = rt;
|
|
2694
|
+
const { jsonReply: jsonReply2, serverlessRequest, backendFor, callRestUrl } = rt;
|
|
2695
|
+
async function diagnoseQueuedJob(endpointId) {
|
|
2696
|
+
const cached = diagnosisCache.get(endpointId);
|
|
2697
|
+
if (cached && cached.expiresAt > Date.now()) {
|
|
2698
|
+
return cached.value;
|
|
2699
|
+
}
|
|
2700
|
+
diagnosisCache.delete(endpointId);
|
|
2701
|
+
try {
|
|
2702
|
+
const backend = backendFor("workers");
|
|
2703
|
+
if (backend.version !== "v2") return null;
|
|
2704
|
+
const raw = await callRestUrl(
|
|
2705
|
+
`${backend.base}/serverless/${endpointId}/workers`
|
|
2706
|
+
);
|
|
2707
|
+
const summary = raw?.summary;
|
|
2708
|
+
const workers = raw?.workers ?? [];
|
|
2709
|
+
if (!summary) return null;
|
|
2710
|
+
const remember = (value) => {
|
|
2711
|
+
if (diagnosisCache.size >= MAX_DIAGNOSIS_ENTRIES) {
|
|
2712
|
+
const oldest = diagnosisCache.keys().next().value;
|
|
2713
|
+
if (oldest !== void 0) diagnosisCache.delete(oldest);
|
|
2714
|
+
}
|
|
2715
|
+
diagnosisCache.set(endpointId, {
|
|
2716
|
+
value,
|
|
2717
|
+
expiresAt: Date.now() + DIAGNOSIS_TTL_MS
|
|
2718
|
+
});
|
|
2719
|
+
return value;
|
|
2720
|
+
};
|
|
2721
|
+
if ((summary.unhealthy ?? 0) > 0) {
|
|
2722
|
+
const badIds = workers.filter((w) => w.status === "UNHEALTHY").map((w) => w.id).join(", ");
|
|
2723
|
+
return remember({
|
|
2724
|
+
workerHealth: summary,
|
|
2725
|
+
hint: `${summary.unhealthy} of ${summary.total} worker(s) on this endpoint are UNHEALTHY \u2014 the container is likely crash-looping (repeated starts that exit before the handler runs). The job can stay IN_QUEUE indefinitely; this is a worker failure, NOT a capacity shortage. Inspect the logs with stream-worker-logs${badIds ? ` (workerId: ${badIds})` : ""}.`
|
|
2726
|
+
});
|
|
2727
|
+
}
|
|
2728
|
+
if ((summary.total ?? 0) === 0) {
|
|
2729
|
+
return remember({
|
|
2730
|
+
workerHealth: summary,
|
|
2731
|
+
hint: "No workers are scheduled for this endpoint yet \u2014 it may simply be scaling up from zero (normal for the first seconds of a cold start), or it is waiting for GPU capacity / its GPU-CUDA constraints exclude the currently-available hosts. If this persists, check availability with list-gpu-types or widen the gpuIds/CUDA settings."
|
|
2732
|
+
});
|
|
2733
|
+
}
|
|
2734
|
+
if ((summary.throttled ?? 0) > 0 && (summary.running ?? 0) === 0) {
|
|
2735
|
+
return remember({
|
|
2736
|
+
workerHealth: summary,
|
|
2737
|
+
hint: "Workers exist but are throttled \u2014 the hosts are at capacity right now; the job should start when capacity frees up."
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
if ((summary.initializing ?? 0) > 0) {
|
|
2741
|
+
return remember({
|
|
2742
|
+
workerHealth: summary,
|
|
2743
|
+
hint: "A worker is initializing \u2014 likely a cold start (image pull / model load); the job should start soon."
|
|
2744
|
+
});
|
|
2745
|
+
}
|
|
2746
|
+
return remember({ workerHealth: summary, hint: "" });
|
|
2747
|
+
} catch {
|
|
2748
|
+
return null;
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2245
2751
|
const endpointIdSchema = import_zod9.z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid endpoint ID format");
|
|
2246
2752
|
const jobIdSchema = import_zod9.z.string().regex(/^[a-zA-Z0-9_-]+$/, "Invalid job ID format");
|
|
2247
2753
|
const inputSchema = import_zod9.z.record(import_zod9.z.unknown()).describe(
|
|
@@ -2315,7 +2821,7 @@ function registerJobTools(server, rt) {
|
|
|
2315
2821
|
);
|
|
2316
2822
|
server.tool(
|
|
2317
2823
|
"get-job-status",
|
|
2318
|
-
"Check the status of an asynchronous Serverless job. Returns the current status and output when complete. Job statuses: IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED, CANCELLED, TIMED_OUT.",
|
|
2824
|
+
"Check the status of an asynchronous Serverless job. Returns the current status and output when complete. Job statuses: IN_QUEUE, IN_PROGRESS, COMPLETED, FAILED, CANCELLED, TIMED_OUT. IMPORTANT: a job stuck IN_QUEUE does not necessarily mean a capacity shortage \u2014 a worker may have spun up and crash-looped (the platform marks it UNHEALTHY while the job stays queued). When the job is IN_QUEUE this tool attaches a workerHealth summary and a hint; to dig deeper, call list-endpoint-workers (look for UNHEALTHY) and stream-worker-logs.",
|
|
2319
2825
|
{
|
|
2320
2826
|
endpointId: endpointIdSchema.describe(
|
|
2321
2827
|
"ID of the Serverless endpoint the job belongs to"
|
|
@@ -2328,6 +2834,16 @@ function registerJobTools(server, rt) {
|
|
|
2328
2834
|
params.endpointId,
|
|
2329
2835
|
`/status/${params.jobId}`
|
|
2330
2836
|
);
|
|
2837
|
+
if (result && typeof result === "object" && result.status === "IN_QUEUE") {
|
|
2838
|
+
const diagnosis = await diagnoseQueuedJob(params.endpointId);
|
|
2839
|
+
if (diagnosis) {
|
|
2840
|
+
return jsonReply2({
|
|
2841
|
+
...result,
|
|
2842
|
+
workerHealth: diagnosis.workerHealth,
|
|
2843
|
+
...diagnosis.hint ? { hint: diagnosis.hint } : {}
|
|
2844
|
+
});
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2331
2847
|
return jsonReply2(result);
|
|
2332
2848
|
}
|
|
2333
2849
|
);
|
|
@@ -2429,7 +2945,7 @@ function registerJobTools(server, rt) {
|
|
|
2429
2945
|
);
|
|
2430
2946
|
server.tool(
|
|
2431
2947
|
"endpoint-health",
|
|
2432
|
-
"Get the health and operational status of a Serverless endpoint, including worker counts and job statistics.",
|
|
2948
|
+
"Get the health and operational status of a Serverless endpoint, including worker counts and job statistics. Use this when jobs are stuck IN_QUEUE: unhealthy workers mean a crash-looping container (a worker failure \u2014 inspect with stream-worker-logs), while zero workers means the endpoint is waiting for GPU capacity.",
|
|
2433
2949
|
{
|
|
2434
2950
|
endpointId: endpointIdSchema.describe(
|
|
2435
2951
|
"ID of the Serverless endpoint to check health for"
|
|
@@ -3067,10 +3583,14 @@ var defaultCredentialChecker = createCredentialChecker({
|
|
|
3067
3583
|
url: () => authGraphqlUrl(process.env)
|
|
3068
3584
|
});
|
|
3069
3585
|
var sameHost = (a, b) => normalizeUrl(a) === normalizeUrl(b);
|
|
3586
|
+
var HOST_ALIASES = {
|
|
3587
|
+
"v2-rest.runpod.io": "api.runpod.io"
|
|
3588
|
+
};
|
|
3070
3589
|
function normalizeUrl(raw) {
|
|
3071
3590
|
try {
|
|
3072
3591
|
const u = new URL(raw);
|
|
3073
|
-
|
|
3592
|
+
const host = u.host.toLowerCase();
|
|
3593
|
+
return `${u.protocol}//${HOST_ALIASES[host] ?? host}${u.pathname.replace(/\/+$/, "")}`;
|
|
3074
3594
|
} catch {
|
|
3075
3595
|
return raw.trim().toLowerCase().replace(/\/+$/, "");
|
|
3076
3596
|
}
|