@runpod/mcp-server 4.10.0 → 4.10.1

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/dist/stdio.mjs CHANGED
@@ -12,7 +12,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
12
12
 
13
13
  // src/server.ts
14
14
  var SERVER_NAME = "Runpod API Server";
15
- var SERVER_VERSION = true ? "4.10.0" : "dev";
15
+ var SERVER_VERSION = true ? "4.10.1" : "dev";
16
16
 
17
17
  // src/specgen/server.ts
18
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
@@ -219,6 +219,14 @@ function validateArguments(inputSchema, args) {
219
219
  }
220
220
  return null;
221
221
  }
222
+ function withoutNullOptionals(inputSchema, args) {
223
+ const required = new Set(inputSchema.required ?? []);
224
+ return Object.fromEntries(
225
+ Object.entries(args).filter(
226
+ ([name, value]) => value !== null || required.has(name)
227
+ )
228
+ );
229
+ }
222
230
  async function dispatchGeneratedTool(client, tool, args) {
223
231
  const rejected = validateArguments(tool.inputSchema, args);
224
232
  if (rejected) return rejected;
@@ -9848,23 +9856,40 @@ function createRunpodClient(options = {}) {
9848
9856
  // src/specgen/clients/sse.ts
9849
9857
  var LOG_STREAM_DEFAULT_WAIT_MS = 5e3;
9850
9858
  var LOG_STREAM_MAX_BYTES = 256 * 1024;
9859
+ var LOG_STREAM_CONNECT_TIMEOUT_MS = 1e4;
9860
+ var LOG_SNAPSHOT_MAX_CHARS = 32e3;
9851
9861
  function createSseReader(options = {}) {
9852
9862
  const apiKey = options.apiKey ?? process.env.RUNPOD_API_KEY;
9853
9863
  const fetchImpl = options.fetchImpl ?? fetch;
9864
+ const connectTimeoutMs = options.connectTimeoutMs ?? LOG_STREAM_CONNECT_TIMEOUT_MS;
9854
9865
  return async (url, opts) => {
9855
9866
  if (!apiKey) throw missingKeyError();
9856
9867
  const chunks = [];
9857
9868
  let bytes = 0;
9858
9869
  let truncated = false;
9859
9870
  let streamEstablished = false;
9871
+ const connect = new AbortController();
9872
+ const connectTimer = setTimeout(
9873
+ () => connect.abort(
9874
+ new DOMException(
9875
+ `Runpod log stream sent no headers within ${connectTimeoutMs}ms`,
9876
+ "TimeoutError"
9877
+ )
9878
+ ),
9879
+ connectTimeoutMs
9880
+ );
9860
9881
  try {
9861
- const response = await createDeadlineFetch(fetchImpl, opts.maxWaitMs)(url, {
9882
+ const response = await createDeadlineFetch(
9883
+ fetchImpl,
9884
+ connectTimeoutMs + opts.maxWaitMs
9885
+ )(url, {
9862
9886
  method: "GET",
9863
9887
  headers: {
9864
9888
  Authorization: `Bearer ${apiKey}`,
9865
9889
  Accept: "text/event-stream"
9866
- }
9867
- });
9890
+ },
9891
+ signal: connect.signal
9892
+ }).finally(() => clearTimeout(connectTimer));
9868
9893
  if (!response.ok) {
9869
9894
  const body = await response.text().catch(() => "");
9870
9895
  throw new HttpError(
@@ -9875,14 +9900,31 @@ function createSseReader(options = {}) {
9875
9900
  }
9876
9901
  streamEstablished = true;
9877
9902
  if (response.body) {
9878
- for await (const chunk of response.body) {
9879
- const boundedChunk = chunk.subarray(0, opts.maxBytes - bytes);
9880
- chunks.push(boundedChunk);
9881
- bytes += boundedChunk.length;
9882
- if (bytes >= opts.maxBytes) {
9883
- truncated = true;
9884
- break;
9903
+ const reader = response.body.getReader();
9904
+ let timer;
9905
+ const windowClosed = new Promise((resolve) => {
9906
+ timer = setTimeout(() => resolve(null), opts.maxWaitMs);
9907
+ });
9908
+ try {
9909
+ for (; ; ) {
9910
+ const read = reader.read();
9911
+ read.catch(() => {
9912
+ });
9913
+ const next = await Promise.race([read, windowClosed]);
9914
+ if (next === null || next.done) break;
9915
+ const boundedChunk = next.value.subarray(0, opts.maxBytes - bytes);
9916
+ chunks.push(boundedChunk);
9917
+ bytes += boundedChunk.length;
9918
+ if (bytes >= opts.maxBytes) {
9919
+ truncated = true;
9920
+ break;
9921
+ }
9885
9922
  }
9923
+ } finally {
9924
+ clearTimeout(timer);
9925
+ void reader.cancel().catch(() => {
9926
+ });
9927
+ reader.releaseLock();
9886
9928
  }
9887
9929
  }
9888
9930
  } catch (err) {
@@ -9908,7 +9950,80 @@ async function collectLogSnapshot(reader, logsUrl, params) {
9908
9950
  for await (const event of iterateLogEvents(new Response(raw).body)) {
9909
9951
  items.push(event.data);
9910
9952
  }
9911
- return { items, count: items.length, truncated };
9953
+ return fitSnapshot(items, truncated);
9954
+ }
9955
+ var serializedLength = (snapshot) => JSON.stringify(snapshot, null, 2).length;
9956
+ var LOG_ENTRY_MAX_CHARS = 16e3;
9957
+ var CLIPPABLE_KEYS = ["line", "raw"];
9958
+ function clipText(text, keep) {
9959
+ const code = text.charCodeAt(keep - 1);
9960
+ if (keep > 0 && code >= 55296 && code <= 56319) keep--;
9961
+ return `${text.slice(0, keep)}\u2026 [clipped ${text.length - keep} chars]`;
9962
+ }
9963
+ function clipEntry(entry) {
9964
+ let clipped = false;
9965
+ const out = { ...entry };
9966
+ for (const key of CLIPPABLE_KEYS) {
9967
+ const text = out[key];
9968
+ if (typeof text === "string" && text.length > LOG_ENTRY_MAX_CHARS) {
9969
+ out[key] = clipText(text, LOG_ENTRY_MAX_CHARS);
9970
+ clipped = true;
9971
+ }
9972
+ }
9973
+ return { entry: out, clipped };
9974
+ }
9975
+ function fitEntry(entry, maxChars) {
9976
+ const size = (e) => serializedLength({ items: [e], count: 1, truncated: false });
9977
+ const out = { ...entry };
9978
+ const keys = Object.keys(out).filter((key) => key !== "ts" && typeof out[key] === "string").sort((a, b) => out[b].length - out[a].length);
9979
+ for (const key of keys) {
9980
+ if (size(out) <= maxChars) break;
9981
+ const text = entry[key];
9982
+ let lo = 0;
9983
+ let hi = Math.min(text.length, LOG_ENTRY_MAX_CHARS);
9984
+ while (lo < hi) {
9985
+ const mid = Math.ceil((lo + hi) / 2);
9986
+ if (size({ ...out, [key]: clipText(text, mid) }) <= maxChars)
9987
+ lo = mid;
9988
+ else hi = mid - 1;
9989
+ }
9990
+ out[key] = clipText(text, lo);
9991
+ }
9992
+ return out;
9993
+ }
9994
+ var entryTs = (entry) => entry?.ts;
9995
+ function fitSnapshot(items, truncated, maxChars = LOG_SNAPSHOT_MAX_CHARS) {
9996
+ let cut = truncated;
9997
+ const entries = items.map((item) => {
9998
+ const { entry, clipped } = clipEntry(item);
9999
+ if (clipped) cut = true;
10000
+ return entry;
10001
+ });
10002
+ const snapshot = (n, isCut) => ({
10003
+ items: entries.slice(0, n),
10004
+ count: n,
10005
+ truncated: isCut
10006
+ });
10007
+ const whole = snapshot(entries.length, cut);
10008
+ if (entries.length === 0 || serializedLength(whole) <= maxChars) return whole;
10009
+ if (serializedLength(snapshot(1, true)) > maxChars) {
10010
+ entries[0] = fitEntry(items[0], maxChars);
10011
+ if (entries.length === 1) return snapshot(1, true);
10012
+ }
10013
+ let lo = 1;
10014
+ let hi = entries.length - 1;
10015
+ while (lo < hi) {
10016
+ const mid = Math.ceil((lo + hi) / 2);
10017
+ if (serializedLength(snapshot(mid, true)) <= maxChars) lo = mid;
10018
+ else hi = mid - 1;
10019
+ }
10020
+ let keep = lo;
10021
+ const omittedTs = entryTs(entries[keep]);
10022
+ if (omittedTs !== void 0) {
10023
+ while (keep > 0 && entryTs(entries[keep - 1]) === omittedTs) keep--;
10024
+ if (keep === 0) keep = lo;
10025
+ }
10026
+ return snapshot(keep, true);
9912
10027
  }
9913
10028
 
9914
10029
  // src/specgen/tools/logs.ts
@@ -9926,13 +10041,13 @@ var logStreamProperties = {
9926
10041
  },
9927
10042
  since: {
9928
10043
  type: "string",
9929
- description: "RFC3339 timestamp to resume from; when set, `tail` is ignored."
10044
+ description: "RFC3339 timestamp to resume after (exclusive). Replays container lines only; system lines stream live from the call. When set, `tail` is ignored."
9930
10045
  },
9931
10046
  maxWaitMs: {
9932
10047
  type: "integer",
9933
10048
  minimum: 500,
9934
10049
  maximum: 3e4,
9935
- description: "How long to read the stream, in ms (default 5000, max 30000)"
10050
+ description: "How long to read the stream once it opens, in ms (default 5000, max 30000)"
9936
10051
  }
9937
10052
  };
9938
10053
  function baseUrl() {
@@ -9958,7 +10073,7 @@ var streamPodLogs = {
9958
10073
  title: "Stream Pod logs",
9959
10074
  holdsResponse: true,
9960
10075
  annotations: readOnly,
9961
- description: "Read a bounded snapshot of a Pod's logs (container and/or system source). Holds the live stream open for maxWaitMs and returns the parsed log lines; `truncated: true` means the byte cap cut the output. Use `since` to resume from a timestamp.",
10076
+ description: "Read a bounded snapshot of a Pod's logs (container and/or system source). Holds the live stream open for maxWaitMs and returns the parsed log lines; `truncated: true` means the size cap cut the output. For later container lines, call again with `since` set to the last container line's `ts`; `since` does not replay system lines, so for those request a smaller `tail`.",
9962
10077
  inputSchema: {
9963
10078
  type: "object",
9964
10079
  properties: {
@@ -9980,10 +10095,10 @@ var streamPodLogs = {
9980
10095
  var streamWorkerLogs = {
9981
10096
  name: "stream-worker-logs",
9982
10097
  title: "Stream worker logs",
9983
- directoryDescription: "Read a bounded snapshot of a Serverless worker's logs (container, system, or both). Holds the live stream open for maxWaitMs and returns the parsed log lines; truncated: true means the byte cap cut the output. A worker whose container keeps failing to start can report RUNNING or THROTTLED while its logs show the failure.",
10098
+ directoryDescription: "Read a bounded snapshot of a Serverless worker's logs (container, system, or both). Holds the live stream open for maxWaitMs and returns the parsed log lines; truncated: true means the size cap cut the output. A worker whose container keeps failing to start can report RUNNING or THROTTLED while its logs show the failure.",
9984
10099
  holdsResponse: true,
9985
10100
  annotations: readOnly,
9986
- description: "Read a bounded snapshot of a Serverless worker's logs (container and/or system source). Holds the live stream open for maxWaitMs and returns the parsed log lines; `truncated: true` means the byte cap cut the output. Get worker IDs from list-endpoint-workers. Use this whenever a job stays IN_QUEUE while workers exist, whatever status they report \u2014 a crash-looping container often still shows RUNNING or THROTTLED with unhealthy 0, so these logs, not the worker status, are what identify a crash loop.",
10101
+ description: "Read a bounded snapshot of a Serverless worker's logs (container and/or system source). Holds the live stream open for maxWaitMs and returns the parsed log lines; `truncated: true` means the size cap cut the output. For later container lines, call again with `since` set to the last container line's `ts`; `since` does not replay system lines, so for those request a smaller `tail`. Get worker IDs from list-endpoint-workers. Use this whenever a job stays IN_QUEUE while workers exist, whatever status they report \u2014 a crash-looping container often still shows RUNNING or THROTTLED with unhealthy 0, so these logs, not the worker status, are what identify a crash loop.",
9987
10102
  inputSchema: {
9988
10103
  type: "object",
9989
10104
  properties: {
@@ -10129,6 +10244,7 @@ import {
10129
10244
  loadKnowledge
10130
10245
  } from "@runpod/plugin-knowledge";
10131
10246
  var knowledge = loadKnowledge();
10247
+ var resolveGuideId = (id) => knowledge.aliases?.[id] ?? id;
10132
10248
  var hiddenRules = new Set(
10133
10249
  (process.env.KNOWLEDGE_HIDDEN_RULES ?? "").split(",").map((id) => id.trim()).filter(Boolean)
10134
10250
  );
@@ -10305,7 +10421,9 @@ var readGuide = {
10305
10421
  title: "Read guide",
10306
10422
  annotations: bundledRead,
10307
10423
  recordedArguments: ["id"],
10308
- recordedGuideKind: (args) => knowledge.guides.find((guide) => guide.id === String(args.id).trim())?.kind,
10424
+ recordedGuideKind: (args) => knowledge.guides.find(
10425
+ (guide) => guide.id === resolveGuideId(String(args.id).trim())
10426
+ )?.kind,
10309
10427
  description: 'Read one official Runpod guide in full, by the id list-guides returns (for example "runpod-usage/storage" or "golden-path/06-dev-pod").',
10310
10428
  inputSchema: {
10311
10429
  type: "object",
@@ -10317,7 +10435,9 @@ var readGuide = {
10317
10435
  },
10318
10436
  handler: async (_ctx, args) => {
10319
10437
  const id = String(args.id).trim();
10320
- const guide = knowledge.guides.find((candidate) => candidate.id === id);
10438
+ const guide = knowledge.guides.find(
10439
+ (candidate) => candidate.id === resolveGuideId(id)
10440
+ );
10321
10441
  if (!guide) {
10322
10442
  const close = knowledge.guides.filter(
10323
10443
  (candidate) => candidate.id.includes(id) || id.includes(candidate.id)
@@ -10472,6 +10592,12 @@ var guideResources = knowledge.guides.map((guide) => ({
10472
10592
  mimeType: "text/markdown",
10473
10593
  text: guide.body
10474
10594
  }));
10595
+ var findGuideResource = (resources, uri) => {
10596
+ const id = uri.startsWith(GUIDE_URI_PREFIX) ? resolveGuideId(uri.slice(GUIDE_URI_PREFIX.length)) : void 0;
10597
+ return resources.find(
10598
+ (resource) => resource.uri === uri || resource.uri === GUIDE_URI_PREFIX + id
10599
+ );
10600
+ };
10475
10601
 
10476
10602
  // src/specgen/prose.ts
10477
10603
  var ABBREVIATIONS = ["e.g.", "i.e.", "etc.", "vs."];
@@ -10719,6 +10845,7 @@ function captureToolCall(apiKey, event) {
10719
10845
  server_version: event.serverVersion,
10720
10846
  ...event.clientName ? { client_name: event.clientName } : {},
10721
10847
  ...event.guideKind ? { guide_kind: event.guideKind } : {},
10848
+ ...event.errorClass ? { error_class: event.errorClass } : {},
10722
10849
  ...event.blockedBy ? { blocked_by: event.blockedBy } : {},
10723
10850
  ...event.profile ? { profile: event.profile } : {},
10724
10851
  ...args
@@ -11496,7 +11623,7 @@ These tools manage infrastructure only. They do not do SSH sessions, file transf
11496
11623
 
11497
11624
  OFFICIAL GUIDES AND FACTS. list-guides and read-guide serve the official Runpod plugin's skills, reference docs and live-verified golden paths, so you have them even without the plugin installed. For a multi-step or billable task, look for a matching golden path first. lookup-concept and search-concepts answer exact questions about how Runpod works (what a stopped pod keeps, where a volume lives) with the public source for each fact.
11498
11625
 
11499
- PLAYBOOKS \u2014 READ BEFORE ACTING. Each Runpod task has a journey playbook carrying its procedure, pitfalls and report format. Before the FIRST Runpod tool call of a session, load the plugin's runpod-mcp skill if you have it, otherwise read-guide runpod-mcp (also the runpod://guides/runpod-mcp resource): it routes the request to a journey guide \u2014 deploying an endpoint reads serverless-deploy, diagnosing a broken pod reads pod-doctor, cost questions read cost-audit, and so on \u2014 and states the answer contract. A reply produced without the routed playbook loaded is out of contract.
11626
+ PLAYBOOKS \u2014 READ BEFORE ACTING. Each Runpod task has a journey playbook carrying its procedure, pitfalls and report format. Before the FIRST Runpod tool call of a session, load the plugin's runpod-mcp skill if you have it, otherwise read-guide runpod-mcp (also the runpod://guides/runpod-mcp resource): it routes the request to a journey guide \u2014 deploying an endpoint reads runpod-serverless-deploy, diagnosing a broken pod reads runpod-pod-doctor, cost questions read runpod-cost-audit, and so on \u2014 and states the answer contract. A reply produced without the routed playbook loaded is out of contract.
11500
11627
 
11501
11628
  THE OFFICIAL RUNPOD PLUGIN. Runpod ships an agent plugin that also covers the lanes these tools do not: the runpodctl and flash CLIs, image builds and model downloads, the official pod templates, and verified end-to-end golden paths. Check once per session whether skills named runpod, runpod-usage, or runpodctl are available to you \u2014 read what you already have, never shell out to look. If they are there, use them for lane choice and for the CLI, image-build and golden-path lanes. For work done with these tools, the journey playbook is the procedure, wherever it is served from: the plugin's runpod-mcp skill and the journey skills it routes to when you have them, otherwise the same playbooks through read-guide here. If you do not see them, recommend the plugin to the user, and recommend updating to the latest if what they have is missing something you need. Point them at https://github.com/runpod/runpod-plugins-official \u2014 its README carries the install, update, and clone commands for each client, so read it yourself when you need the exact one rather than guessing a command. This is a recommendation, never a prerequisite: make it once, briefly, and get on with the task. Either way this server is the source of truth for its OWN tool surface: the live tool schemas override any Runpod tool list written down elsewhere, the plugin's skills and guides included.
11502
11629
 
@@ -11557,9 +11684,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11557
11684
  resourceTemplates: []
11558
11685
  }));
11559
11686
  server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
11560
- const guide = servedGuides.find(
11561
- (candidate) => candidate.uri === request.params.uri
11562
- );
11687
+ const guide = findGuideResource(servedGuides, request.params.uri);
11563
11688
  if (opts.analytics) {
11564
11689
  captureResourceCall(ctx.apiKey, {
11565
11690
  method: "resources/read",
@@ -11596,6 +11721,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11596
11721
  ok: false,
11597
11722
  status: 429,
11598
11723
  durationMs: 0,
11724
+ errorClass: "rate_limited",
11599
11725
  ...verdict.reason ? { blockedBy: verdict.reason } : {},
11600
11726
  profile,
11601
11727
  ...opts.analytics
@@ -11621,6 +11747,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11621
11747
  const generated = servedGeneratedTools.find(
11622
11748
  (tool) => tool.name === request.params.name
11623
11749
  );
11750
+ const curatedArgs = curated ? withoutNullOptionals(curated.inputSchema, args) : args;
11624
11751
  const omitted = directory ? directoryOmittedInputs(request.params.name).filter((key) => key in args) : [];
11625
11752
  const rejected = omitted.length ? {
11626
11753
  ok: false,
@@ -11630,10 +11757,10 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11630
11757
  }
11631
11758
  } : curated && !curated.lenientArguments ? validateArguments(
11632
11759
  curated.inputSchema,
11633
- args
11760
+ curatedArgs
11634
11761
  ) : null;
11635
11762
  const result = rejected ?? await runTool(
11636
- () => curated ? curated.handler(ctx, args) : dispatchTool(generated, request.params.name, args)
11763
+ () => curated ? curated.handler(ctx, curatedArgs) : dispatchTool(generated, request.params.name, args)
11637
11764
  );
11638
11765
  logToolCall({
11639
11766
  tool: request.params.name,
@@ -11648,6 +11775,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11648
11775
  ok: result.ok,
11649
11776
  status: result.status,
11650
11777
  durationMs: Date.now() - startedAt,
11778
+ ...result.ok ? {} : { errorClass: errorClass(result) },
11651
11779
  // The directory profile records which tool ran, never what was asked.
11652
11780
  ...directory ? {} : {
11653
11781
  args: recordedArgumentValues(curated?.recordedArguments, args),
@@ -11687,24 +11815,50 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11687
11815
  var SHAPE_HINT = "Input shape mismatch: check this tool's schema for required fields. For job payloads, the worker's release config defines the expected input object \u2014 fix the payload and retry; don't abandon the task.";
11688
11816
  var DETAIL_HINTS = [
11689
11817
  [
11690
- /no longer any instances available|not enough free GPUs/i,
11691
- "Capacity, not input: the request is valid but no machine can take it right now. Don't change the payload shape. Retry with another GPU type, data center, cloud, CUDA version or count, or try again later. For a stopped Pod, its host's GPUs are taken; that Pod can only start once they free up."
11818
+ /no longer any instances available|no instances currently available|not enough free GPUs/i,
11819
+ "Capacity, not input: the request is valid but no machine can take it right now. Don't change the payload shape. Retry with another GPU type, data center, cloud, CUDA version or count, or try again later. For a stopped Pod, its host's GPUs are taken; that Pod can only start once they free up.",
11820
+ "capacity"
11692
11821
  ],
11693
11822
  [
11694
11823
  /at least \$\d|insufficient (?:funds|balance)|\bbalance\b/i,
11695
- "Account balance, not input: this operation needs funds on the account. Ask the user to add credit in the Runpod console; list-billing shows recent spend."
11824
+ "Account balance, not input: this operation needs funds on the account. Ask the user to add credit in the Runpod console; list-billing shows recent spend.",
11825
+ "balance"
11696
11826
  ],
11697
11827
  [
11698
11828
  /mutually exclusive|cannot be combined with/i,
11699
- "Two arguments conflict: send only one of them and leave the other out entirely. Do not change anything else."
11829
+ "Two arguments conflict: send only one of them and leave the other out entirely. Do not change anything else.",
11830
+ "conflicting_args"
11831
+ ],
11832
+ // A 400 with a bare "Unauthorized" detail from a write the same key can
11833
+ // otherwise make is upstream's answer for an unfunded account.
11834
+ [
11835
+ /^\s*unauthorized\s*$/i,
11836
+ "Upstream refused this write. With a key that can read, this usually means the account has no balance: ask the user to check their balance in the Runpod console before retrying.",
11837
+ "balance"
11700
11838
  ]
11701
11839
  ];
11840
+ function errorClass(result) {
11841
+ const { status } = result;
11842
+ const payload = asRecord(result.payload) ?? { error: result.payload };
11843
+ const detail = detailText(payload);
11844
+ if (status === 400 && detail) {
11845
+ for (const [pattern, , cls] of DETAIL_HINTS)
11846
+ if (pattern.test(detail)) return cls;
11847
+ }
11848
+ if (status === 400 || status === 422) return "validation";
11849
+ if (status === 401) return "auth";
11850
+ if (status === 402) return "balance";
11851
+ if (status === 403) return "permission";
11852
+ if (status === 404) return "not_found";
11853
+ if (status === 429) return "rate_limited";
11854
+ if (status === 504) return "timeout";
11855
+ if (status >= 500) return "server";
11856
+ return "other";
11857
+ }
11702
11858
  function errorHint(status, detail) {
11703
11859
  if (status === 400 && detail) {
11704
11860
  for (const [pattern, hint] of DETAIL_HINTS)
11705
11861
  if (pattern.test(detail)) return hint;
11706
- if (/^unauthorized$/i.test(detail.trim()))
11707
- return "Upstream refused this write. With a key that can read, this usually means the account has no balance: ask the user to check their balance in the Runpod console before retrying.";
11708
11862
  }
11709
11863
  if (status === 400 || status === 422) return SHAPE_HINT;
11710
11864
  if (status === 401) return "The Runpod API key is missing or invalid.";