@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/hosted.cjs CHANGED
@@ -312,6 +312,14 @@ function validateArguments(inputSchema, args) {
312
312
  }
313
313
  return null;
314
314
  }
315
+ function withoutNullOptionals(inputSchema, args) {
316
+ const required = new Set(inputSchema.required ?? []);
317
+ return Object.fromEntries(
318
+ Object.entries(args).filter(
319
+ ([name, value]) => value !== null || required.has(name)
320
+ )
321
+ );
322
+ }
315
323
  async function dispatchGeneratedTool(client, tool, args) {
316
324
  const rejected = validateArguments(tool.inputSchema, args);
317
325
  if (rejected) return rejected;
@@ -9965,23 +9973,40 @@ function createRunpodClient(options = {}) {
9965
9973
  // src/specgen/clients/sse.ts
9966
9974
  var LOG_STREAM_DEFAULT_WAIT_MS = 5e3;
9967
9975
  var LOG_STREAM_MAX_BYTES = 256 * 1024;
9976
+ var LOG_STREAM_CONNECT_TIMEOUT_MS = 1e4;
9977
+ var LOG_SNAPSHOT_MAX_CHARS = 32e3;
9968
9978
  function createSseReader(options = {}) {
9969
9979
  const apiKey = options.apiKey ?? process.env.RUNPOD_API_KEY;
9970
9980
  const fetchImpl = options.fetchImpl ?? fetch;
9981
+ const connectTimeoutMs = options.connectTimeoutMs ?? LOG_STREAM_CONNECT_TIMEOUT_MS;
9971
9982
  return async (url, opts) => {
9972
9983
  if (!apiKey) throw missingKeyError();
9973
9984
  const chunks = [];
9974
9985
  let bytes = 0;
9975
9986
  let truncated = false;
9976
9987
  let streamEstablished = false;
9988
+ const connect = new AbortController();
9989
+ const connectTimer = setTimeout(
9990
+ () => connect.abort(
9991
+ new DOMException(
9992
+ `Runpod log stream sent no headers within ${connectTimeoutMs}ms`,
9993
+ "TimeoutError"
9994
+ )
9995
+ ),
9996
+ connectTimeoutMs
9997
+ );
9977
9998
  try {
9978
- const response = await createDeadlineFetch(fetchImpl, opts.maxWaitMs)(url, {
9999
+ const response = await createDeadlineFetch(
10000
+ fetchImpl,
10001
+ connectTimeoutMs + opts.maxWaitMs
10002
+ )(url, {
9979
10003
  method: "GET",
9980
10004
  headers: {
9981
10005
  Authorization: `Bearer ${apiKey}`,
9982
10006
  Accept: "text/event-stream"
9983
- }
9984
- });
10007
+ },
10008
+ signal: connect.signal
10009
+ }).finally(() => clearTimeout(connectTimer));
9985
10010
  if (!response.ok) {
9986
10011
  const body = await response.text().catch(() => "");
9987
10012
  throw new HttpError(
@@ -9992,14 +10017,31 @@ function createSseReader(options = {}) {
9992
10017
  }
9993
10018
  streamEstablished = true;
9994
10019
  if (response.body) {
9995
- for await (const chunk of response.body) {
9996
- const boundedChunk = chunk.subarray(0, opts.maxBytes - bytes);
9997
- chunks.push(boundedChunk);
9998
- bytes += boundedChunk.length;
9999
- if (bytes >= opts.maxBytes) {
10000
- truncated = true;
10001
- break;
10020
+ const reader = response.body.getReader();
10021
+ let timer;
10022
+ const windowClosed = new Promise((resolve) => {
10023
+ timer = setTimeout(() => resolve(null), opts.maxWaitMs);
10024
+ });
10025
+ try {
10026
+ for (; ; ) {
10027
+ const read = reader.read();
10028
+ read.catch(() => {
10029
+ });
10030
+ const next = await Promise.race([read, windowClosed]);
10031
+ if (next === null || next.done) break;
10032
+ const boundedChunk = next.value.subarray(0, opts.maxBytes - bytes);
10033
+ chunks.push(boundedChunk);
10034
+ bytes += boundedChunk.length;
10035
+ if (bytes >= opts.maxBytes) {
10036
+ truncated = true;
10037
+ break;
10038
+ }
10002
10039
  }
10040
+ } finally {
10041
+ clearTimeout(timer);
10042
+ void reader.cancel().catch(() => {
10043
+ });
10044
+ reader.releaseLock();
10003
10045
  }
10004
10046
  }
10005
10047
  } catch (err) {
@@ -10025,7 +10067,80 @@ async function collectLogSnapshot(reader, logsUrl, params) {
10025
10067
  for await (const event of iterateLogEvents(new Response(raw).body)) {
10026
10068
  items.push(event.data);
10027
10069
  }
10028
- return { items, count: items.length, truncated };
10070
+ return fitSnapshot(items, truncated);
10071
+ }
10072
+ var serializedLength = (snapshot) => JSON.stringify(snapshot, null, 2).length;
10073
+ var LOG_ENTRY_MAX_CHARS = 16e3;
10074
+ var CLIPPABLE_KEYS = ["line", "raw"];
10075
+ function clipText(text, keep) {
10076
+ const code = text.charCodeAt(keep - 1);
10077
+ if (keep > 0 && code >= 55296 && code <= 56319) keep--;
10078
+ return `${text.slice(0, keep)}\u2026 [clipped ${text.length - keep} chars]`;
10079
+ }
10080
+ function clipEntry(entry) {
10081
+ let clipped = false;
10082
+ const out = { ...entry };
10083
+ for (const key of CLIPPABLE_KEYS) {
10084
+ const text = out[key];
10085
+ if (typeof text === "string" && text.length > LOG_ENTRY_MAX_CHARS) {
10086
+ out[key] = clipText(text, LOG_ENTRY_MAX_CHARS);
10087
+ clipped = true;
10088
+ }
10089
+ }
10090
+ return { entry: out, clipped };
10091
+ }
10092
+ function fitEntry(entry, maxChars) {
10093
+ const size = (e) => serializedLength({ items: [e], count: 1, truncated: false });
10094
+ const out = { ...entry };
10095
+ const keys = Object.keys(out).filter((key) => key !== "ts" && typeof out[key] === "string").sort((a, b) => out[b].length - out[a].length);
10096
+ for (const key of keys) {
10097
+ if (size(out) <= maxChars) break;
10098
+ const text = entry[key];
10099
+ let lo = 0;
10100
+ let hi = Math.min(text.length, LOG_ENTRY_MAX_CHARS);
10101
+ while (lo < hi) {
10102
+ const mid = Math.ceil((lo + hi) / 2);
10103
+ if (size({ ...out, [key]: clipText(text, mid) }) <= maxChars)
10104
+ lo = mid;
10105
+ else hi = mid - 1;
10106
+ }
10107
+ out[key] = clipText(text, lo);
10108
+ }
10109
+ return out;
10110
+ }
10111
+ var entryTs = (entry) => entry?.ts;
10112
+ function fitSnapshot(items, truncated, maxChars = LOG_SNAPSHOT_MAX_CHARS) {
10113
+ let cut = truncated;
10114
+ const entries = items.map((item) => {
10115
+ const { entry, clipped } = clipEntry(item);
10116
+ if (clipped) cut = true;
10117
+ return entry;
10118
+ });
10119
+ const snapshot = (n, isCut) => ({
10120
+ items: entries.slice(0, n),
10121
+ count: n,
10122
+ truncated: isCut
10123
+ });
10124
+ const whole = snapshot(entries.length, cut);
10125
+ if (entries.length === 0 || serializedLength(whole) <= maxChars) return whole;
10126
+ if (serializedLength(snapshot(1, true)) > maxChars) {
10127
+ entries[0] = fitEntry(items[0], maxChars);
10128
+ if (entries.length === 1) return snapshot(1, true);
10129
+ }
10130
+ let lo = 1;
10131
+ let hi = entries.length - 1;
10132
+ while (lo < hi) {
10133
+ const mid = Math.ceil((lo + hi) / 2);
10134
+ if (serializedLength(snapshot(mid, true)) <= maxChars) lo = mid;
10135
+ else hi = mid - 1;
10136
+ }
10137
+ let keep = lo;
10138
+ const omittedTs = entryTs(entries[keep]);
10139
+ if (omittedTs !== void 0) {
10140
+ while (keep > 0 && entryTs(entries[keep - 1]) === omittedTs) keep--;
10141
+ if (keep === 0) keep = lo;
10142
+ }
10143
+ return snapshot(keep, true);
10029
10144
  }
10030
10145
 
10031
10146
  // src/specgen/tools/logs.ts
@@ -10043,13 +10158,13 @@ var logStreamProperties = {
10043
10158
  },
10044
10159
  since: {
10045
10160
  type: "string",
10046
- description: "RFC3339 timestamp to resume from; when set, `tail` is ignored."
10161
+ description: "RFC3339 timestamp to resume after (exclusive). Replays container lines only; system lines stream live from the call. When set, `tail` is ignored."
10047
10162
  },
10048
10163
  maxWaitMs: {
10049
10164
  type: "integer",
10050
10165
  minimum: 500,
10051
10166
  maximum: 3e4,
10052
- description: "How long to read the stream, in ms (default 5000, max 30000)"
10167
+ description: "How long to read the stream once it opens, in ms (default 5000, max 30000)"
10053
10168
  }
10054
10169
  };
10055
10170
  function baseUrl() {
@@ -10075,7 +10190,7 @@ var streamPodLogs = {
10075
10190
  title: "Stream Pod logs",
10076
10191
  holdsResponse: true,
10077
10192
  annotations: readOnly,
10078
- 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.",
10193
+ 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`.",
10079
10194
  inputSchema: {
10080
10195
  type: "object",
10081
10196
  properties: {
@@ -10097,10 +10212,10 @@ var streamPodLogs = {
10097
10212
  var streamWorkerLogs = {
10098
10213
  name: "stream-worker-logs",
10099
10214
  title: "Stream worker logs",
10100
- 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.",
10215
+ 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.",
10101
10216
  holdsResponse: true,
10102
10217
  annotations: readOnly,
10103
- 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.",
10218
+ 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.",
10104
10219
  inputSchema: {
10105
10220
  type: "object",
10106
10221
  properties: {
@@ -10244,6 +10359,7 @@ var listPublicEndpoints = {
10244
10359
  // src/specgen/tools/knowledge.ts
10245
10360
  var import_plugin_knowledge = require("@runpod/plugin-knowledge");
10246
10361
  var knowledge = (0, import_plugin_knowledge.loadKnowledge)();
10362
+ var resolveGuideId = (id) => knowledge.aliases?.[id] ?? id;
10247
10363
  var hiddenRules = new Set(
10248
10364
  (process.env.KNOWLEDGE_HIDDEN_RULES ?? "").split(",").map((id) => id.trim()).filter(Boolean)
10249
10365
  );
@@ -10420,7 +10536,9 @@ var readGuide = {
10420
10536
  title: "Read guide",
10421
10537
  annotations: bundledRead,
10422
10538
  recordedArguments: ["id"],
10423
- recordedGuideKind: (args) => knowledge.guides.find((guide) => guide.id === String(args.id).trim())?.kind,
10539
+ recordedGuideKind: (args) => knowledge.guides.find(
10540
+ (guide) => guide.id === resolveGuideId(String(args.id).trim())
10541
+ )?.kind,
10424
10542
  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").',
10425
10543
  inputSchema: {
10426
10544
  type: "object",
@@ -10432,7 +10550,9 @@ var readGuide = {
10432
10550
  },
10433
10551
  handler: async (_ctx, args) => {
10434
10552
  const id = String(args.id).trim();
10435
- const guide = knowledge.guides.find((candidate) => candidate.id === id);
10553
+ const guide = knowledge.guides.find(
10554
+ (candidate) => candidate.id === resolveGuideId(id)
10555
+ );
10436
10556
  if (!guide) {
10437
10557
  const close = knowledge.guides.filter(
10438
10558
  (candidate) => candidate.id.includes(id) || id.includes(candidate.id)
@@ -10587,6 +10707,12 @@ var guideResources = knowledge.guides.map((guide) => ({
10587
10707
  mimeType: "text/markdown",
10588
10708
  text: guide.body
10589
10709
  }));
10710
+ var findGuideResource = (resources, uri) => {
10711
+ const id = uri.startsWith(GUIDE_URI_PREFIX) ? resolveGuideId(uri.slice(GUIDE_URI_PREFIX.length)) : void 0;
10712
+ return resources.find(
10713
+ (resource) => resource.uri === uri || resource.uri === GUIDE_URI_PREFIX + id
10714
+ );
10715
+ };
10590
10716
 
10591
10717
  // src/specgen/prose.ts
10592
10718
  var ABBREVIATIONS = ["e.g.", "i.e.", "etc.", "vs."];
@@ -10770,7 +10896,7 @@ function words2(name) {
10770
10896
 
10771
10897
  // src/server.ts
10772
10898
  var SERVER_NAME = "Runpod API Server";
10773
- var SERVER_VERSION = true ? "4.10.0" : "dev";
10899
+ var SERVER_VERSION = true ? "4.10.1" : "dev";
10774
10900
 
10775
10901
  // src/specgen/analytics.ts
10776
10902
  var import_node_crypto = require("crypto");
@@ -10838,6 +10964,7 @@ function captureToolCall(apiKey, event) {
10838
10964
  server_version: event.serverVersion,
10839
10965
  ...event.clientName ? { client_name: event.clientName } : {},
10840
10966
  ...event.guideKind ? { guide_kind: event.guideKind } : {},
10967
+ ...event.errorClass ? { error_class: event.errorClass } : {},
10841
10968
  ...event.blockedBy ? { blocked_by: event.blockedBy } : {},
10842
10969
  ...event.profile ? { profile: event.profile } : {},
10843
10970
  ...args
@@ -11461,7 +11588,7 @@ These tools manage infrastructure only. They do not do SSH sessions, file transf
11461
11588
 
11462
11589
  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.
11463
11590
 
11464
- 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.
11591
+ 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.
11465
11592
 
11466
11593
  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.
11467
11594
 
@@ -11522,9 +11649,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11522
11649
  resourceTemplates: []
11523
11650
  }));
11524
11651
  server.setRequestHandler(import_types.ReadResourceRequestSchema, async (request) => {
11525
- const guide = servedGuides.find(
11526
- (candidate) => candidate.uri === request.params.uri
11527
- );
11652
+ const guide = findGuideResource(servedGuides, request.params.uri);
11528
11653
  if (opts.analytics) {
11529
11654
  captureResourceCall(ctx.apiKey, {
11530
11655
  method: "resources/read",
@@ -11561,6 +11686,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11561
11686
  ok: false,
11562
11687
  status: 429,
11563
11688
  durationMs: 0,
11689
+ errorClass: "rate_limited",
11564
11690
  ...verdict.reason ? { blockedBy: verdict.reason } : {},
11565
11691
  profile,
11566
11692
  ...opts.analytics
@@ -11586,6 +11712,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11586
11712
  const generated = servedGeneratedTools.find(
11587
11713
  (tool) => tool.name === request.params.name
11588
11714
  );
11715
+ const curatedArgs = curated ? withoutNullOptionals(curated.inputSchema, args) : args;
11589
11716
  const omitted = directory ? directoryOmittedInputs(request.params.name).filter((key) => key in args) : [];
11590
11717
  const rejected = omitted.length ? {
11591
11718
  ok: false,
@@ -11595,10 +11722,10 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11595
11722
  }
11596
11723
  } : curated && !curated.lenientArguments ? validateArguments(
11597
11724
  curated.inputSchema,
11598
- args
11725
+ curatedArgs
11599
11726
  ) : null;
11600
11727
  const result = rejected ?? await runTool(
11601
- () => curated ? curated.handler(ctx, args) : dispatchTool(generated, request.params.name, args)
11728
+ () => curated ? curated.handler(ctx, curatedArgs) : dispatchTool(generated, request.params.name, args)
11602
11729
  );
11603
11730
  logToolCall({
11604
11731
  tool: request.params.name,
@@ -11613,6 +11740,7 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11613
11740
  ok: result.ok,
11614
11741
  status: result.status,
11615
11742
  durationMs: Date.now() - startedAt,
11743
+ ...result.ok ? {} : { errorClass: errorClass(result) },
11616
11744
  // The directory profile records which tool ran, never what was asked.
11617
11745
  ...directory ? {} : {
11618
11746
  args: recordedArgumentValues(curated?.recordedArguments, args),
@@ -11652,24 +11780,50 @@ function createSpecgenServer(ctx, version = "0.1.0", opts = {}) {
11652
11780
  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.";
11653
11781
  var DETAIL_HINTS = [
11654
11782
  [
11655
- /no longer any instances available|not enough free GPUs/i,
11656
- "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."
11783
+ /no longer any instances available|no instances currently available|not enough free GPUs/i,
11784
+ "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.",
11785
+ "capacity"
11657
11786
  ],
11658
11787
  [
11659
11788
  /at least \$\d|insufficient (?:funds|balance)|\bbalance\b/i,
11660
- "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."
11789
+ "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.",
11790
+ "balance"
11661
11791
  ],
11662
11792
  [
11663
11793
  /mutually exclusive|cannot be combined with/i,
11664
- "Two arguments conflict: send only one of them and leave the other out entirely. Do not change anything else."
11794
+ "Two arguments conflict: send only one of them and leave the other out entirely. Do not change anything else.",
11795
+ "conflicting_args"
11796
+ ],
11797
+ // A 400 with a bare "Unauthorized" detail from a write the same key can
11798
+ // otherwise make is upstream's answer for an unfunded account.
11799
+ [
11800
+ /^\s*unauthorized\s*$/i,
11801
+ "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.",
11802
+ "balance"
11665
11803
  ]
11666
11804
  ];
11805
+ function errorClass(result) {
11806
+ const { status } = result;
11807
+ const payload = asRecord(result.payload) ?? { error: result.payload };
11808
+ const detail = detailText(payload);
11809
+ if (status === 400 && detail) {
11810
+ for (const [pattern, , cls] of DETAIL_HINTS)
11811
+ if (pattern.test(detail)) return cls;
11812
+ }
11813
+ if (status === 400 || status === 422) return "validation";
11814
+ if (status === 401) return "auth";
11815
+ if (status === 402) return "balance";
11816
+ if (status === 403) return "permission";
11817
+ if (status === 404) return "not_found";
11818
+ if (status === 429) return "rate_limited";
11819
+ if (status === 504) return "timeout";
11820
+ if (status >= 500) return "server";
11821
+ return "other";
11822
+ }
11667
11823
  function errorHint(status, detail) {
11668
11824
  if (status === 400 && detail) {
11669
11825
  for (const [pattern, hint] of DETAIL_HINTS)
11670
11826
  if (pattern.test(detail)) return hint;
11671
- if (/^unauthorized$/i.test(detail.trim()))
11672
- 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.";
11673
11827
  }
11674
11828
  if (status === 400 || status === 422) return SHAPE_HINT;
11675
11829
  if (status === 401) return "The Runpod API key is missing or invalid.";