runbios-mcp 0.2.1-dev.62

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.
Files changed (90) hide show
  1. package/README.md +363 -0
  2. package/dist/api-client.d.ts +147 -0
  3. package/dist/api-client.d.ts.map +1 -0
  4. package/dist/api-client.js +806 -0
  5. package/dist/api-client.js.map +1 -0
  6. package/dist/auth.d.ts +3 -0
  7. package/dist/auth.d.ts.map +1 -0
  8. package/dist/auth.js +11 -0
  9. package/dist/auth.js.map +1 -0
  10. package/dist/config.d.ts +30 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +16 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/deployment-contract.d.ts +78 -0
  15. package/dist/deployment-contract.d.ts.map +1 -0
  16. package/dist/deployment-contract.js +155 -0
  17. package/dist/deployment-contract.js.map +1 -0
  18. package/dist/gpu-priorities.d.ts +25 -0
  19. package/dist/gpu-priorities.d.ts.map +1 -0
  20. package/dist/gpu-priorities.js +61 -0
  21. package/dist/gpu-priorities.js.map +1 -0
  22. package/dist/http/app.d.ts +14 -0
  23. package/dist/http/app.d.ts.map +1 -0
  24. package/dist/http/app.js +140 -0
  25. package/dist/http/app.js.map +1 -0
  26. package/dist/http/audit.d.ts +23 -0
  27. package/dist/http/audit.d.ts.map +1 -0
  28. package/dist/http/audit.js +41 -0
  29. package/dist/http/audit.js.map +1 -0
  30. package/dist/http/config.d.ts +23 -0
  31. package/dist/http/config.d.ts.map +1 -0
  32. package/dist/http/config.js +70 -0
  33. package/dist/http/config.js.map +1 -0
  34. package/dist/http/consent.d.ts +18 -0
  35. package/dist/http/consent.d.ts.map +1 -0
  36. package/dist/http/consent.js +148 -0
  37. package/dist/http/consent.js.map +1 -0
  38. package/dist/http/internal-auth.d.ts +22 -0
  39. package/dist/http/internal-auth.d.ts.map +1 -0
  40. package/dist/http/internal-auth.js +73 -0
  41. package/dist/http/internal-auth.js.map +1 -0
  42. package/dist/http/main.d.ts +3 -0
  43. package/dist/http/main.d.ts.map +1 -0
  44. package/dist/http/main.js +65 -0
  45. package/dist/http/main.js.map +1 -0
  46. package/dist/http/mcp-handler.d.ts +12 -0
  47. package/dist/http/mcp-handler.d.ts.map +1 -0
  48. package/dist/http/mcp-handler.js +103 -0
  49. package/dist/http/mcp-handler.js.map +1 -0
  50. package/dist/http/metadata.d.ts +6 -0
  51. package/dist/http/metadata.d.ts.map +1 -0
  52. package/dist/http/metadata.js +32 -0
  53. package/dist/http/metadata.js.map +1 -0
  54. package/dist/http/oauth.d.ts +56 -0
  55. package/dist/http/oauth.d.ts.map +1 -0
  56. package/dist/http/oauth.js +486 -0
  57. package/dist/http/oauth.js.map +1 -0
  58. package/dist/http/serviceHostGuard.d.ts +31 -0
  59. package/dist/http/serviceHostGuard.d.ts.map +1 -0
  60. package/dist/http/serviceHostGuard.js +68 -0
  61. package/dist/http/serviceHostGuard.js.map +1 -0
  62. package/dist/http/store.d.ts +150 -0
  63. package/dist/http/store.d.ts.map +1 -0
  64. package/dist/http/store.js +366 -0
  65. package/dist/http/store.js.map +1 -0
  66. package/dist/index.d.ts +3 -0
  67. package/dist/index.d.ts.map +1 -0
  68. package/dist/index.js +79 -0
  69. package/dist/index.js.map +1 -0
  70. package/dist/inference-contract.d.ts +29 -0
  71. package/dist/inference-contract.d.ts.map +1 -0
  72. package/dist/inference-contract.js +112 -0
  73. package/dist/inference-contract.js.map +1 -0
  74. package/dist/redaction.d.ts +74 -0
  75. package/dist/redaction.d.ts.map +1 -0
  76. package/dist/redaction.js +316 -0
  77. package/dist/redaction.js.map +1 -0
  78. package/dist/server.d.ts +63 -0
  79. package/dist/server.d.ts.map +1 -0
  80. package/dist/server.js +2282 -0
  81. package/dist/server.js.map +1 -0
  82. package/dist/training-contract.d.ts +107 -0
  83. package/dist/training-contract.d.ts.map +1 -0
  84. package/dist/training-contract.js +141 -0
  85. package/dist/training-contract.js.map +1 -0
  86. package/dist/version.d.ts +2 -0
  87. package/dist/version.d.ts.map +1 -0
  88. package/dist/version.js +2 -0
  89. package/dist/version.js.map +1 -0
  90. package/package.json +47 -0
@@ -0,0 +1,806 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { redactBuildIdentity, redactBuildIdentityDeep } from "./redaction.js";
3
+ import { VERSION } from "./version.js";
4
+ /* ─── Standard GPU rejection contract ─── */
5
+ /**
6
+ * The ONE transient GPU rejection code: the GPU is not in stock right now.
7
+ * Stock changes, so retrying later or joining the capacity queue is a real
8
+ * answer here (and ONLY here).
9
+ */
10
+ export const CAPACITY_UNAVAILABLE_CODE = "CAPACITY_UNAVAILABLE";
11
+ /**
12
+ * Deprecated training-side alias of CAPACITY_UNAVAILABLE, still echoed for one
13
+ * release. It means the same transient thing.
14
+ */
15
+ export const LEGACY_CAPACITY_UNAVAILABLE_CODE = "SELECTED_GPU_UNAVAILABLE";
16
+ /**
17
+ * Permanent GPU rejection codes. Each one is a fixed fact about the request
18
+ * itself: the model does not fit on that GPU, the GPU count is below what the
19
+ * model needs, the count cannot be used for this model, or that GPU cannot run
20
+ * the model at all. Free stock never changes any of them, so waiting and the
21
+ * capacity queue can never turn one of these into a booking. Callers MUST NOT
22
+ * offer the queue for these.
23
+ */
24
+ export const PERMANENT_GPU_CODES = [
25
+ "GPU_TYPE_TOO_SMALL",
26
+ "GPU_COUNT_BELOW_MINIMUM",
27
+ "GPU_COUNT_INVALID",
28
+ "GPU_TYPE_UNSUPPORTED",
29
+ ];
30
+ /**
31
+ * reason discriminator to machine code, mirroring the server-side table. An
32
+ * unknown or missing reason resolves to the transient code, which is also what
33
+ * a plain stock miss carries.
34
+ */
35
+ const GPU_REJECTION_CODE_BY_REASON = {
36
+ insufficient_stock: CAPACITY_UNAVAILABLE_CODE,
37
+ model_too_large: "GPU_TYPE_TOO_SMALL",
38
+ below_model_minimum: "GPU_COUNT_BELOW_MINIMUM",
39
+ invalid_gpu_count: "GPU_COUNT_INVALID",
40
+ gpu_unsupported: "GPU_TYPE_UNSUPPORTED",
41
+ };
42
+ /** Resolve the machine code for a rejection reason. */
43
+ export function gpuRejectionCodeFor(reason) {
44
+ return GPU_REJECTION_CODE_BY_REASON[reason ?? ""] ?? CAPACITY_UNAVAILABLE_CODE;
45
+ }
46
+ /** True when this code can never be fixed by waiting for stock. */
47
+ export function isPermanentGpuCode(code) {
48
+ return typeof code === "string" && PERMANENT_GPU_CODES.includes(code);
49
+ }
50
+ /**
51
+ * HTTP status for a rejection reason: 409 for a stock miss (a retry can resolve
52
+ * it), 400 for a permanent rejection the request itself has to change.
53
+ */
54
+ export function gpuRejectionStatusFor(reason) {
55
+ return isPermanentGpuCode(gpuRejectionCodeFor(reason)) ? 400 : 409;
56
+ }
57
+ /** Every code whose body carries the standard GPU rejection fields. */
58
+ export function isGpuRejectionCode(code) {
59
+ return code === CAPACITY_UNAVAILABLE_CODE
60
+ || code === LEGACY_CAPACITY_UNAVAILABLE_CODE
61
+ || isPermanentGpuCode(code);
62
+ }
63
+ /**
64
+ * The recovery instruction handed to the calling agent. It branches on whether
65
+ * the rejection is permanent, because "wait for stock" is actively harmful
66
+ * advice on a request that can never book: the agent would park a deployment
67
+ * that no amount of capacity can satisfy.
68
+ */
69
+ export function gpuRejectionInstruction(opts) {
70
+ const call = opts.call || "the same call";
71
+ if (opts.permanent) {
72
+ const fix = opts.hasAlternatives
73
+ ? `Retry ${call} with gpu_type and gpu_count set to one of available_gpus, never below that entry's min_gpus.`
74
+ : `Read minimum_requirement.per_type for the GPU types that fit this model and the GPU count each one needs, then retry ${call} with one of those.`;
75
+ return `This GPU cannot run the request as submitted, and free capacity will never change that. ${fix} `
76
+ + "Do not set allow_capacity_queue, and do not send the same gpu_type and gpu_count again: "
77
+ + "neither waiting nor the queue can make this request fit.";
78
+ }
79
+ return opts.hasAlternatives
80
+ ? `The selected GPU is not bookable for this request. Retry ${call} with gpu_type/gpu_count set to one of available_gpus (never below its min_gpus).`
81
+ : "No alternative GPUs are in stock right now. Retry shortly, or resubmit with allow_capacity_queue=true to wait for stock (nothing is charged while waiting).";
82
+ }
83
+ /* ─── Error text an agent can act on ─── */
84
+ /**
85
+ * What a caller should DO about a status when the API sent no usable message.
86
+ * An MCP client is an AI agent: "Run BiOS API error 400" tells it nothing, so it
87
+ * either retries blindly or reports a dead end to the user. These sentences say
88
+ * what class of problem it is and what the next move is — without inventing a
89
+ * cause the response did not state.
90
+ */
91
+ function statusGuidance(status) {
92
+ if (status === 400)
93
+ return "the API rejected the request as invalid but sent no reason. Re-read the tool's input schema; do not retry the identical payload.";
94
+ if (status === 401)
95
+ return "the credential was not accepted. Check the API key/token configured for this MCP server, then call introspect_api_key.";
96
+ if (status === 403)
97
+ return "the credential is authenticated but not allowed to do this. Call introspect_api_key to see the scopes and workspace it is bound to.";
98
+ if (status === 404)
99
+ return "no such resource in this workspace. List the parent collection to get a valid id instead of guessing one.";
100
+ if (status === 409)
101
+ return "the request conflicts with current state. Re-read the resource's status before retrying.";
102
+ if (status === 413)
103
+ return "the payload is too large for this endpoint.";
104
+ if (status === 429)
105
+ return "rate limited. Wait and retry; do not loop.";
106
+ if (status >= 500)
107
+ return "a server-side failure, not a verdict about the request. Retry shortly; if it persists, report it rather than changing the payload.";
108
+ return "no error message was returned.";
109
+ }
110
+ /**
111
+ * Pull the human-readable message out of an error body. The platform speaks
112
+ * several shapes: Go services answer `{error:{code,message}}` or `{message}`,
113
+ * the Python dataset service answers FastAPI's `{detail: "..."}` (or a
114
+ * validation array of `{loc,msg}`). Only the first two were read before, so
115
+ * every dataset-service rejection — list_integrations' missing-workspace 400
116
+ * among them — degraded to a bare "Run BiOS API error 400".
117
+ */
118
+ function extractApiMessage(err) {
119
+ const nested = typeof err.error === "object" && err.error !== null ? err.error : {};
120
+ if (typeof nested.message === "string" && nested.message.trim())
121
+ return nested.message.trim();
122
+ if (typeof err.error === "string" && err.error.trim())
123
+ return err.error.trim();
124
+ if (typeof err.message === "string" && err.message.trim())
125
+ return err.message.trim();
126
+ const detail = err.detail;
127
+ if (typeof detail === "string" && detail.trim())
128
+ return detail.trim();
129
+ if (Array.isArray(detail)) {
130
+ const parts = detail
131
+ .map((entry) => {
132
+ if (!entry || typeof entry !== "object")
133
+ return String(entry);
134
+ const item = entry;
135
+ const where = Array.isArray(item.loc) ? item.loc.filter((p) => p !== "body").join(".") : "";
136
+ const msg = typeof item.msg === "string" ? item.msg : typeof item.message === "string" ? item.message : "";
137
+ return where && msg ? `${where}: ${msg}` : msg || where;
138
+ })
139
+ .filter(Boolean);
140
+ if (parts.length > 0)
141
+ return parts.join("; ");
142
+ }
143
+ if (typeof err.title === "string" && err.title.trim())
144
+ return err.title.trim();
145
+ return "";
146
+ }
147
+ const NOT_FOUND_ROUTES = [
148
+ {
149
+ test: /^\/api\/inference\/bookings\/([^/]+)/,
150
+ idFrom: 1,
151
+ resource: {
152
+ code: "BOOKING_NOT_FOUND",
153
+ resource: "booking_handle",
154
+ // A booking handle is minted by ONE create_inference 202 and expires; it
155
+ // is not listable, so "list the collection" would be wrong advice here.
156
+ recovery: "A booking handle exists only for the create_inference call that minted it, and it expires. Nothing was charged and no deployment exists for an unknown handle. "
157
+ + "Call list_inferences to see whether the deployment was in fact created; if it was not, re-issue create_inference with the SAME idempotency_key rather than polling this handle again.",
158
+ },
159
+ },
160
+ {
161
+ test: /^\/api\/training\/jobs\/([^/]+)\/checkpoints\/([^/]+)/,
162
+ idFrom: 2,
163
+ resource: {
164
+ code: "CHECKPOINT_NOT_FOUND",
165
+ resource: "checkpoint_id",
166
+ recovery: "Call get_training_checkpoints with the same job_id to list the checkpoints that actually exist, and copy an id from that response.",
167
+ },
168
+ },
169
+ {
170
+ test: /^\/api\/training\/jobs\/([^/]+)/,
171
+ idFrom: 1,
172
+ resource: {
173
+ code: "TRAINING_JOB_NOT_FOUND",
174
+ resource: "job_id",
175
+ recovery: "Call list_training_jobs to get the job ids in this workspace and copy one from that response. A job id from another workspace is never visible here.",
176
+ },
177
+ },
178
+ {
179
+ test: /^\/api\/datasets\/integrations\/([^/]+)/,
180
+ idFrom: 1,
181
+ resource: {
182
+ code: "INTEGRATION_NOT_FOUND",
183
+ resource: "integration_id",
184
+ recovery: "Call list_integrations to get the connected accounts in this workspace and copy an id from that response.",
185
+ },
186
+ },
187
+ {
188
+ // The negative lookahead keeps a 404 from a STATIC endpoint under the same
189
+ // prefix (a collection read, an upload, a validate) from being reported as
190
+ // "no dataset with id 'upload'".
191
+ test: /^\/api\/datasets\/(?!integrations|uploads?|validate|register-hf|storage-usage|storage-billing|format-specs|hub-search|hub-preview)([^/]+)/,
192
+ idFrom: 1,
193
+ resource: {
194
+ code: "DATASET_NOT_FOUND",
195
+ resource: "dataset_id",
196
+ recovery: "Call list_datasets to get the dataset ids in this workspace and copy one from that response. A dataset that is still importing is listed but not yet previewable — read its status there before concluding it is missing.",
197
+ },
198
+ },
199
+ {
200
+ test: /^\/api\/inference\/(?!bookings|preflight|gpu-options|gpu-availability|validate-model)([^/]+)/,
201
+ idFrom: 1,
202
+ resource: {
203
+ code: "INFERENCE_NOT_FOUND",
204
+ resource: "deployment_id",
205
+ recovery: "Call list_inferences to get the deployment ids in this workspace and copy one from that response. A deleted deployment is gone permanently — it does not come back.",
206
+ },
207
+ },
208
+ ];
209
+ /**
210
+ * Structured answer for a 404. Deliberately NOT gated on the upstream message
211
+ * text: whichever service answered and in whatever prose, a 404 on an
212
+ * id-bearing path means the same thing and needs the same next move.
213
+ */
214
+ function notFoundError(status, message, path, upstreamCode) {
215
+ for (const route of NOT_FOUND_ROUTES) {
216
+ const match = route.test.exec(path);
217
+ if (!match)
218
+ continue;
219
+ const id = decodeURIComponent(match[route.idFrom] ?? "");
220
+ return JSON.stringify({
221
+ code: route.resource.code,
222
+ message: `${message || "Not found"} — no ${route.resource.resource} "${id}" exists in this workspace (an id from another workspace is never visible, and a deleted one never returns).`,
223
+ recoverable: true,
224
+ instruction: `${route.resource.recovery} Do NOT retry this call with the same id, and do not treat this as an outage — the platform answered definitively.`,
225
+ resource: route.resource.resource,
226
+ id,
227
+ path,
228
+ upstream_code: upstreamCode,
229
+ });
230
+ }
231
+ // An unmapped path: still structured, still says what to do, but never
232
+ // invents a collection to list.
233
+ return JSON.stringify({
234
+ code: "RESOURCE_NOT_FOUND",
235
+ message: `${message || "Not found"} — ${statusGuidance(status)}`,
236
+ recoverable: true,
237
+ instruction: "The platform answered definitively that this resource does not exist in this workspace. List the parent collection to obtain a valid id instead of retrying, and do not treat this as an outage.",
238
+ path,
239
+ upstream_code: upstreamCode,
240
+ });
241
+ }
242
+ /**
243
+ * Format a failed control-plane response as error text an agent can act on.
244
+ *
245
+ * Four shapes come out of here:
246
+ * 1. Capacity-class rejections stay the structured compact JSON of the
247
+ * standard capacity contract (book-first §3) — unchanged.
248
+ * 2. A missing-workspace 400 becomes a structured, self-recoverable error
249
+ * naming the exact fix, because "Workspace context required" alone does
250
+ * not tell an agent where workspace context comes from.
251
+ * 3. A 404 becomes a structured `<THING>_NOT_FOUND` naming the id, the
252
+ * resource, and the read that hands out valid ids — the same shape as
253
+ * MODEL_NOT_HOSTED, so an unknown id never has to be string-matched out
254
+ * of a bare sentence.
255
+ * 4. Everything else keeps the API's own prose (so a real message is never
256
+ * paraphrased), and a response with NO message gets the status, the path,
257
+ * and what to do about it instead of a bare error code.
258
+ *
259
+ * CUSTOMER BOUNDARY: whichever shape comes out, it is redacted before it is
260
+ * returned. Every one of the four carries text the platform did not author —
261
+ * an upstream message, a proxy body, an engine exception — so build identity
262
+ * (image name/tag/digest/version) can ride any of them out to an MCP client.
263
+ * The redaction is applied HERE rather than at the single throw site so the
264
+ * exported helper cannot be reused later without it.
265
+ */
266
+ export function formatApiError(status, text, path) {
267
+ const formatted = buildApiErrorText(status, text, path);
268
+ // Redact structurally when the shape is JSON, so a replacement can never
269
+ // land inside a string and corrupt the payload the agent has to parse.
270
+ try {
271
+ const parsed = JSON.parse(formatted);
272
+ if (parsed && typeof parsed === "object") {
273
+ return JSON.stringify(redactBuildIdentityDeep(parsed));
274
+ }
275
+ }
276
+ catch {
277
+ /* not JSON — plain prose, redacted as text below */
278
+ }
279
+ return redactBuildIdentity(formatted);
280
+ }
281
+ function buildApiErrorText(status, text, path) {
282
+ let err;
283
+ try {
284
+ const parsed = JSON.parse(text);
285
+ if (parsed && typeof parsed === "object")
286
+ err = parsed;
287
+ }
288
+ catch {
289
+ /* non-JSON body handled below */
290
+ }
291
+ if (!err) {
292
+ // A non-JSON 404 (a proxy's own page, an empty body) is still an unknown
293
+ // id as far as the caller is concerned, so it gets the structured answer.
294
+ // An HTML error page is not a message: quoting it would bury the
295
+ // instruction, so only a short plain body is carried through.
296
+ if (status === 404) {
297
+ const body = text.trim();
298
+ const usable = body.length <= 200 && !body.startsWith("<") ? body : "";
299
+ return notFoundError(status, usable, path, undefined);
300
+ }
301
+ const body = text.trim();
302
+ return body || `Run BiOS API error ${status} on ${path} — ${statusGuidance(status)}`;
303
+ }
304
+ const message = extractApiMessage(err);
305
+ const nested = typeof err.error === "object" && err.error !== null ? err.error : {};
306
+ // Availability rejections are SELF-RECOVERABLE: the API includes the machine
307
+ // code plus the currently bookable GPU alternatives. An MCP client is an AI
308
+ // agent — hand it the structured payload as compact JSON in the error text so
309
+ // it can pick a listed gpu_type and retry the same call, instead of a prose
310
+ // sentence it can only show a human.
311
+ //
312
+ // Standard capacity contract (book-first §3): the fields may live nested
313
+ // under `error` or at the top level; a capacity-class code is ALWAYS
314
+ // serialized structured — even with ZERO alternatives (the zero-alternative
315
+ // miss must still carry the minimum block and the queue option, not degrade
316
+ // to prose).
317
+ const alternatives = nested.available_gpus ?? nested.available_alternatives
318
+ ?? err.available_gpus ?? err.available_alternatives;
319
+ const code = (nested.code || err.code);
320
+ const isCapacityClass = code === "CAPACITY_UNAVAILABLE" || code === "SELECTED_GPU_UNAVAILABLE";
321
+ if (isCapacityClass || (Array.isArray(alternatives) && alternatives.length > 0)) {
322
+ const alternativeList = Array.isArray(alternatives) ? alternatives : [];
323
+ return JSON.stringify({
324
+ code: code || `HTTP_${status}`,
325
+ reason: nested.reason ?? err.reason,
326
+ message: message || `Run BiOS API error ${status}`,
327
+ recoverable: true,
328
+ instruction: alternativeList.length > 0
329
+ ? "The selected GPU is not bookable for this request. Retry the same call with gpu_type/gpu_count set to one of available_gpus (never below its min_gpus)."
330
+ : "No alternative GPUs are in stock right now. Retry shortly, or resubmit with allow_capacity_queue=true to wait for stock (nothing is charged while waiting).",
331
+ available_gpus: alternativeList,
332
+ minimum_requirement: nested.minimum_requirement ?? err.minimum_requirement,
333
+ selected: nested.selected ?? err.selected,
334
+ queue_eligible: nested.queue_eligible ?? err.queue_eligible,
335
+ checked_at: nested.checked_at ?? err.checked_at,
336
+ });
337
+ }
338
+ // Workspace context is not something the model can put in a tool argument —
339
+ // it comes from the MCP server's configuration (RUNBIOS_WORKSPACE_ID) or from
340
+ // the workspace an API key is bound to. Say that, or the agent retries the
341
+ // same call forever.
342
+ if (status === 400 && /workspace (context|id) (is )?required|workspace context required/i.test(message)) {
343
+ return JSON.stringify({
344
+ code: "WORKSPACE_CONTEXT_REQUIRED",
345
+ message: `${message} — this call is workspace-scoped and the request carried no workspace.`,
346
+ recoverable: false,
347
+ instruction: "The workspace cannot be passed as a tool argument. Either set RUNBIOS_WORKSPACE_ID in this MCP server's environment or use an API key bound to a workspace, then restart the MCP client. Call introspect_api_key to see which workspace (if any) the current credential is bound to. Tell the user this is a configuration fix, not a retry.",
348
+ path,
349
+ });
350
+ }
351
+ // An unknown id is a verdict about the ARGUMENT, so it must arrive as
352
+ // structured data with the route back to a valid id — never as a sentence the
353
+ // caller has to parse.
354
+ if (status === 404)
355
+ return notFoundError(status, message, path, code);
356
+ return message || `Run BiOS API error ${status} on ${path} — ${statusGuidance(status)}`;
357
+ }
358
+ export class BiosClient {
359
+ baseUrl;
360
+ authHeaders;
361
+ orgId;
362
+ workspaceId;
363
+ inferenceBaseUrl;
364
+ inferenceAuthHeader;
365
+ serverlessAuthHeader;
366
+ inferenceTimeoutMs;
367
+ userAgent;
368
+ constructor(opts) {
369
+ this.baseUrl = opts.baseUrl.replace(/\/+$/, "");
370
+ this.authHeaders = { ...opts.authHeaders };
371
+ this.orgId = opts.orgId ?? "";
372
+ this.workspaceId = opts.workspaceId ?? "";
373
+ this.inferenceBaseUrl = (opts.inferenceBaseUrl || this.baseUrl).replace(/\/+$/, "");
374
+ this.inferenceAuthHeader = opts.inferenceAuthHeader ?? "";
375
+ this.serverlessAuthHeader = opts.serverlessAuthHeader ?? "";
376
+ this.inferenceTimeoutMs = opts.inferenceTimeoutMs ?? 900_000;
377
+ this.userAgent = opts.userAgent ?? `bios-mcp/${VERSION}`;
378
+ }
379
+ /**
380
+ * Tri-state probe against the public model registry (model-service):
381
+ * "hosted" — the exact author/name id is a verified catalog model;
382
+ * "not_hosted" — the registry definitively answered 404; "unknown" — the
383
+ * registry could not answer (network/5xx). Callers must treat "unknown" as
384
+ * advisory only and let the server-side gate enforce: never fail closed.
385
+ */
386
+ async modelRegistryStatus(modelId) {
387
+ const id = modelId.trim();
388
+ // Catalog ids are exactly "author/name" with safe segments; anything else
389
+ // can never resolve, so answer without a network call.
390
+ const segments = id.split("/");
391
+ if (segments.length !== 2
392
+ || !segments.every((s) => /^[A-Za-z0-9._-]+$/.test(s) && s !== "." && s !== "..")) {
393
+ return "not_hosted";
394
+ }
395
+ let res;
396
+ try {
397
+ res = await fetch(`${this.baseUrl}/api/models/${id}`, {
398
+ headers: { "User-Agent": this.userAgent },
399
+ });
400
+ }
401
+ catch {
402
+ return "unknown";
403
+ }
404
+ // Drain the body so the connection can be reused; only the status matters.
405
+ await res.text().catch(() => "");
406
+ if (res.ok)
407
+ return "hosted";
408
+ if (res.status === 404)
409
+ return "not_hosted";
410
+ return "unknown";
411
+ }
412
+ async api(path, opts = {}) {
413
+ const { method = "GET", body, params, formData, headers: extraHeaders } = opts;
414
+ const url = new URL(path, this.baseUrl);
415
+ if (params) {
416
+ for (const [k, v] of Object.entries(params)) {
417
+ if (v !== undefined)
418
+ url.searchParams.set(k, v);
419
+ }
420
+ }
421
+ const headers = {
422
+ "User-Agent": this.userAgent,
423
+ };
424
+ if (this.orgId)
425
+ headers["X-Org-ID"] = this.orgId;
426
+ Object.assign(headers, this.authHeaders);
427
+ if (extraHeaders)
428
+ Object.assign(headers, extraHeaders);
429
+ if (this.workspaceId)
430
+ headers["X-Workspace-ID"] = this.workspaceId;
431
+ let reqBody;
432
+ if (formData) {
433
+ reqBody = formData;
434
+ }
435
+ else if (body) {
436
+ headers["Content-Type"] = "application/json";
437
+ reqBody = JSON.stringify(body);
438
+ }
439
+ const res = await fetch(url.toString(), { method, headers, body: reqBody });
440
+ const text = await res.text();
441
+ if (!res.ok) {
442
+ // GPU rejections are SELF-RECOVERABLE: the API includes the machine
443
+ // code plus the GPUs that can actually run this request. An MCP client
444
+ // is an AI agent, so hand it the structured payload as compact JSON in
445
+ // the error text and it can pick a listed gpu_type and retry, instead
446
+ // of a prose sentence it can only show a human.
447
+ //
448
+ // Standard GPU rejection contract (book-first §3): the fields may live
449
+ // nested under `error` or at the top level; EVERY code in the family is
450
+ // ALWAYS serialized structured, even with ZERO alternatives (the
451
+ // zero-alternative body still carries the minimum block, and for a
452
+ // stock miss the queue option, so it must not degrade to prose).
453
+ //
454
+ // The family is two classes and the difference is load-bearing:
455
+ // CAPACITY_UNAVAILABLE is "not in stock right now" and the queue can
456
+ // fix it, while every PERMANENT_GPU_CODES member is a fixed fact about
457
+ // the request that no amount of stock can fix. Both get the structured
458
+ // payload; only the transient one gets pointed at the queue.
459
+ //
460
+ // Every text field the payload carries (message, reason, the nested
461
+ // selection) can be raw upstream prose, so the whole structured object
462
+ // is run through redactBuildIdentityDeep before it is stringified — the
463
+ // same boundary formatApiError applies to every other failed call. A
464
+ // non-GPU error falls through to formatApiError, which redacts too.
465
+ let structuredError;
466
+ try {
467
+ const err = JSON.parse(text);
468
+ const nested = typeof err.error === 'object' && err.error !== null ? err.error : {};
469
+ const alternatives = nested.available_gpus ?? nested.available_alternatives
470
+ ?? err.available_gpus ?? err.available_alternatives;
471
+ const code = nested.code || err.code;
472
+ if (isGpuRejectionCode(code) || (Array.isArray(alternatives) && alternatives.length > 0)) {
473
+ let baseMsg = `Run BiOS API error ${res.status}`;
474
+ if (err.message)
475
+ baseMsg = err.message;
476
+ if (err.error) {
477
+ if (typeof err.error === 'object' && err.error.message) {
478
+ baseMsg = err.error.message;
479
+ }
480
+ else if (typeof err.error === 'string') {
481
+ baseMsg = err.error;
482
+ }
483
+ }
484
+ const alternativeList = Array.isArray(alternatives) ? alternatives : [];
485
+ const queueOffered = nested.queue_offered ?? err.queue_offered;
486
+ const priorityEntry = nested.gpu_priorities_entry ?? err.gpu_priorities_entry;
487
+ // queue_offered === false is the server saying the queue is not on the
488
+ // table, which is exactly the permanent classes. Reading the code and
489
+ // the flag keeps this right even if one of them is missing.
490
+ const permanent = isPermanentGpuCode(code) || queueOffered === false;
491
+ const structured = {
492
+ code: code || `HTTP_${res.status}`,
493
+ status: res.status,
494
+ reason: nested.reason ?? err.reason,
495
+ message: baseMsg,
496
+ // Recoverable by the agent on its own, either way. What differs is
497
+ // HOW: a stock miss can resend the same body later, a permanent
498
+ // rejection has to change gpu_type/gpu_count first.
499
+ recoverable: true,
500
+ retryable_as_submitted: !permanent,
501
+ instruction: gpuRejectionInstruction({
502
+ permanent,
503
+ hasAlternatives: alternativeList.length > 0,
504
+ }),
505
+ available_gpus: alternativeList,
506
+ minimum_requirement: nested.minimum_requirement ?? err.minimum_requirement,
507
+ selected: nested.selected ?? err.selected,
508
+ queue_offered: queueOffered,
509
+ queue_eligible: nested.queue_eligible ?? err.queue_eligible,
510
+ checked_at: nested.checked_at ?? err.checked_at,
511
+ };
512
+ // 1-based rank of the gpu_priorities entry that was refused, so a
513
+ // caller with a ranked ladder knows which entry to change.
514
+ if (priorityEntry !== undefined)
515
+ structured.gpu_priorities_entry = priorityEntry;
516
+ structuredError = JSON.stringify(redactBuildIdentityDeep(structured));
517
+ }
518
+ }
519
+ catch {
520
+ /* not a GPU rejection body — fall through to formatApiError */
521
+ }
522
+ // formatApiError is the single redaction exit for every failed
523
+ // control-plane call: the text it returns can be raw upstream prose (an
524
+ // engine exception, a body we could not parse) that would otherwise reach
525
+ // the MCP client verbatim as the tool error.
526
+ throw new Error(structuredError ?? formatApiError(res.status, text, path));
527
+ }
528
+ // A body of literally `null` (or an empty body) must never reach a tool
529
+ // result as a bare `null`: an agent that reads "null" learns nothing about
530
+ // whether the call succeeded, failed, or returned no rows. Normalize the
531
+ // empty answer to an empty object; every tool then renders a JSON object.
532
+ const parsed = text ? JSON.parse(text) : null;
533
+ return (parsed ?? {});
534
+ }
535
+ /**
536
+ * Select the Authorization header for /v1/chat/completions. A dedicated
537
+ * deployment key (inferenceAuthHeader) wins when present; otherwise a
538
+ * serverless-scoped platform key (serverlessAuthHeader) authenticates
539
+ * model-ID-routed serverless catalog calls on the unified endpoint.
540
+ */
541
+ resolveInferenceAuth() {
542
+ const auth = this.inferenceAuthHeader || this.serverlessAuthHeader;
543
+ if (!auth) {
544
+ throw new Error("chat_with_inference requires a key: set RUNBIOS_INFERENCE_KEY for a dedicated deployment, "
545
+ + "or provide a workspace platform key (RUNBIOS_API_KEY with serverless scope) to call catalog models by id");
546
+ }
547
+ return auth;
548
+ }
549
+ async inferenceApi(body, idempotencyKey, requestId, callerSignal) {
550
+ // Route streaming requests through the SSE aggregator so reasoning +
551
+ // content deltas surface exactly as produced, collapsed to one result.
552
+ if (body.stream === true) {
553
+ return this.streamInferenceApi(body, idempotencyKey, requestId, callerSignal);
554
+ }
555
+ const headers = {
556
+ Authorization: this.resolveInferenceAuth(),
557
+ Accept: "application/json",
558
+ "Content-Type": "application/json",
559
+ "X-Request-ID": requestId || randomUUID(),
560
+ "User-Agent": this.userAgent,
561
+ };
562
+ if (idempotencyKey)
563
+ headers["Idempotency-Key"] = idempotencyKey;
564
+ // Inference POSTs are intentionally dispatched once. An MCP caller that
565
+ // retries can reuse idempotency_key; this client never replays implicitly.
566
+ const controller = new AbortController();
567
+ const relayAbort = () => controller.abort(callerSignal?.reason);
568
+ if (callerSignal?.aborted)
569
+ relayAbort();
570
+ else
571
+ callerSignal?.addEventListener("abort", relayAbort, { once: true });
572
+ const timeout = setTimeout(() => controller.abort(new Error(`Inference timed out after ${this.inferenceTimeoutMs}ms`)), this.inferenceTimeoutMs);
573
+ let response;
574
+ let text;
575
+ try {
576
+ response = await fetch(`${this.inferenceBaseUrl}/v1/chat/completions`, {
577
+ method: "POST",
578
+ headers,
579
+ body: JSON.stringify(body),
580
+ signal: controller.signal,
581
+ });
582
+ text = await response.text();
583
+ }
584
+ finally {
585
+ clearTimeout(timeout);
586
+ callerSignal?.removeEventListener("abort", relayAbort);
587
+ }
588
+ if (!response.ok) {
589
+ // Serving errors are engine output; redact any build identity before the
590
+ // MCP client sees them as the tool error.
591
+ try {
592
+ const parsed = JSON.parse(text);
593
+ throw new Error(redactBuildIdentity(parsed?.error?.message || parsed?.message || `Inference API error ${response.status}`));
594
+ }
595
+ catch (error) {
596
+ if (error instanceof SyntaxError)
597
+ throw new Error(redactBuildIdentity(text || `Inference API error ${response.status}`));
598
+ throw error;
599
+ }
600
+ }
601
+ let parsed;
602
+ try {
603
+ parsed = JSON.parse(text);
604
+ }
605
+ catch {
606
+ throw new Error("Inference endpoint returned invalid JSON");
607
+ }
608
+ // `null` is not a completion. Returning it verbatim would hand the caller a
609
+ // bare "null" tool result that reads as "the model answered nothing".
610
+ if (parsed === null || typeof parsed !== "object") {
611
+ throw new Error("Inference endpoint returned an empty body (no completion object). Nothing was generated; retry the call.");
612
+ }
613
+ return parsed;
614
+ }
615
+ /**
616
+ * POST a streaming /v1/chat/completions request, consume the SSE, and
617
+ * aggregate the deltas into ONE OpenAI-shaped completion. Content and
618
+ * reasoning_content are concatenated in arrival order (so reasoning surfaces
619
+ * as produced), tool-call fragments are reassembled by index, and the final
620
+ * usage row (requested via stream_options.include_usage) is attached. The
621
+ * MCP transport returns a single tool result, so streaming is used for
622
+ * on-the-wire liveness/real-TTFT parity while the caller receives the
623
+ * assembled message.
624
+ */
625
+ async streamInferenceApi(body, idempotencyKey, requestId, callerSignal) {
626
+ const headers = {
627
+ Authorization: this.resolveInferenceAuth(),
628
+ Accept: "text/event-stream",
629
+ "Content-Type": "application/json",
630
+ "X-Request-ID": requestId || randomUUID(),
631
+ "User-Agent": this.userAgent,
632
+ };
633
+ if (idempotencyKey)
634
+ headers["Idempotency-Key"] = idempotencyKey;
635
+ const controller = new AbortController();
636
+ const relayAbort = () => controller.abort(callerSignal?.reason);
637
+ if (callerSignal?.aborted)
638
+ relayAbort();
639
+ else
640
+ callerSignal?.addEventListener("abort", relayAbort, { once: true });
641
+ const timeout = setTimeout(() => controller.abort(new Error(`Inference timed out after ${this.inferenceTimeoutMs}ms`)), this.inferenceTimeoutMs);
642
+ try {
643
+ const response = await fetch(`${this.inferenceBaseUrl}/v1/chat/completions`, {
644
+ method: "POST",
645
+ headers,
646
+ body: JSON.stringify(body),
647
+ signal: controller.signal,
648
+ });
649
+ if (!response.ok) {
650
+ const text = await response.text().catch(() => "");
651
+ try {
652
+ const parsed = JSON.parse(text);
653
+ throw new Error(redactBuildIdentity(parsed?.error?.message || parsed?.message || `Inference API error ${response.status}`));
654
+ }
655
+ catch (error) {
656
+ if (error instanceof SyntaxError)
657
+ throw new Error(redactBuildIdentity(text || `Inference API error ${response.status}`));
658
+ throw error;
659
+ }
660
+ }
661
+ if (!response.body)
662
+ throw new Error("Inference stream returned no body");
663
+ return await aggregateChatStream(response.body);
664
+ }
665
+ finally {
666
+ clearTimeout(timeout);
667
+ callerSignal?.removeEventListener("abort", relayAbort);
668
+ }
669
+ }
670
+ }
671
+ /**
672
+ * Parse the OpenAI chat-completion SSE from `stream` and fold its deltas into a
673
+ * single non-streaming-shaped response. Content and reasoning_content are
674
+ * concatenated in arrival order; tool-call fragments are merged by index;
675
+ * finish_reason, role, id, model, and the final usage row are captured.
676
+ */
677
+ export async function aggregateChatStream(stream) {
678
+ const reader = stream.getReader();
679
+ const decoder = new TextDecoder();
680
+ let buffer = "";
681
+ let content = "";
682
+ let reasoning = "";
683
+ let role = "assistant";
684
+ let finishReason = null;
685
+ let id;
686
+ let model;
687
+ let created;
688
+ let usage;
689
+ const toolCalls = [];
690
+ const consume = (payload) => {
691
+ const data = payload.trim();
692
+ if (!data || data === "[DONE]")
693
+ return;
694
+ let event;
695
+ try {
696
+ event = JSON.parse(data);
697
+ }
698
+ catch {
699
+ return; // ignore non-JSON keepalive frames
700
+ }
701
+ if (event.error) {
702
+ const err = event.error;
703
+ const msg = typeof err === "string" ? err : String(err?.message ?? "stream error");
704
+ throw new Error(redactBuildIdentity(msg));
705
+ }
706
+ if (typeof event.id === "string")
707
+ id = event.id;
708
+ if (typeof event.model === "string")
709
+ model = event.model;
710
+ if (typeof event.created === "number")
711
+ created = event.created;
712
+ if (event.usage)
713
+ usage = event.usage;
714
+ const choices = Array.isArray(event.choices) ? event.choices : [];
715
+ for (const rawChoice of choices) {
716
+ const choice = rawChoice;
717
+ if (typeof choice.finish_reason === "string")
718
+ finishReason = choice.finish_reason;
719
+ const delta = (choice.delta ?? {});
720
+ if (typeof delta.role === "string")
721
+ role = delta.role;
722
+ if (typeof delta.content === "string")
723
+ content += delta.content;
724
+ if (typeof delta.reasoning_content === "string")
725
+ reasoning += delta.reasoning_content;
726
+ const deltaCalls = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
727
+ for (const rawCall of deltaCalls) {
728
+ const call = rawCall;
729
+ const index = typeof call.index === "number" ? call.index : toolCalls.length;
730
+ const slot = toolCalls[index] ?? (toolCalls[index] = { function: { arguments: "" } });
731
+ if (typeof call.id === "string")
732
+ slot.id = call.id;
733
+ if (typeof call.type === "string")
734
+ slot.type = call.type;
735
+ const fn = (call.function ?? {});
736
+ if (typeof fn.name === "string")
737
+ slot.function.name = fn.name;
738
+ if (typeof fn.arguments === "string")
739
+ slot.function.arguments += fn.arguments;
740
+ }
741
+ }
742
+ };
743
+ try {
744
+ for (;;) {
745
+ const { done, value } = await reader.read();
746
+ if (done)
747
+ break;
748
+ buffer += decoder.decode(value, { stream: true });
749
+ for (;;) {
750
+ const match = /\r\n\r\n|\n\n|\r\r/.exec(buffer);
751
+ if (!match || match.index === undefined)
752
+ break;
753
+ const frame = buffer.slice(0, match.index);
754
+ buffer = buffer.slice(match.index + match[0].length);
755
+ emitFrame(frame, consume);
756
+ }
757
+ }
758
+ buffer += decoder.decode();
759
+ if (buffer.trim())
760
+ emitFrame(buffer, consume);
761
+ }
762
+ finally {
763
+ await reader.cancel().catch(() => undefined);
764
+ reader.releaseLock();
765
+ }
766
+ const message = { role, content: content || null };
767
+ if (reasoning)
768
+ message.reasoning_content = reasoning;
769
+ const assembledCalls = toolCalls.filter((c) => c !== undefined);
770
+ if (assembledCalls.length > 0) {
771
+ message.tool_calls = assembledCalls.map((c, i) => ({
772
+ id: c.id ?? `call_${i}`,
773
+ type: c.type ?? "function",
774
+ function: { name: c.function.name ?? "", arguments: c.function.arguments },
775
+ }));
776
+ }
777
+ const result = {
778
+ id: id ?? "",
779
+ object: "chat.completion",
780
+ model: model ?? "",
781
+ choices: [{ index: 0, message, finish_reason: finishReason }],
782
+ };
783
+ if (created !== undefined)
784
+ result.created = created;
785
+ if (usage !== undefined)
786
+ result.usage = usage;
787
+ return result;
788
+ }
789
+ /** Extract the joined `data:` payload from one SSE frame and consume it. */
790
+ function emitFrame(frame, consume) {
791
+ const data = [];
792
+ for (const line of frame.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n")) {
793
+ if (!line || line.startsWith(":"))
794
+ continue;
795
+ const colon = line.indexOf(":");
796
+ const field = colon < 0 ? line : line.slice(0, colon);
797
+ let value = colon < 0 ? "" : line.slice(colon + 1);
798
+ if (value.startsWith(" "))
799
+ value = value.slice(1);
800
+ if (field === "data")
801
+ data.push(value);
802
+ }
803
+ if (data.length > 0)
804
+ consume(data.join("\n"));
805
+ }
806
+ //# sourceMappingURL=api-client.js.map