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
package/dist/server.js ADDED
@@ -0,0 +1,2282 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { readFile, stat } from "node:fs/promises";
4
+ import { basename, resolve } from "node:path";
5
+ import { randomUUID } from "node:crypto";
6
+ import { buildGPUOptionsParams, buildTrainingCreateBody, TRAINING_ADAPTERS, TRAINING_METHODS, trimTrainingLogs, } from "./training-contract.js";
7
+ import { buildDeploymentBody, buildDeploymentGPUOptionsParams, buildDeploymentPolicyBody, DEPLOYMENT_GPU_TIERS, DEPLOYMENT_SOURCE_TYPES, } from "./deployment-contract.js";
8
+ import { buildInferenceBody } from "./inference-contract.js";
9
+ import { redactBuildIdentity, redactBuildIdentityDeep, redactFreeTextFields, sanitizeArchitectureRegistry, stripBuildIdentity, } from "./redaction.js";
10
+ import { gpuRejectionCodeFor, gpuRejectionInstruction, gpuRejectionStatusFor, isPermanentGpuCode, } from "./api-client.js";
11
+ /**
12
+ * Job/deployment fields whose upstream value is RAW text rather than a curated
13
+ * sentence. `stop_last_error` is the proven one: the training stop saga stores
14
+ * the transport error from its last call to the machine verbatim, and that text
15
+ * is the request URL — a hostname that names the capacity supplier. The others
16
+ * are the same class (free text written from an upstream failure) and are
17
+ * covered so a future writer that stops curating cannot reopen the hole.
18
+ */
19
+ const RAW_FAILURE_TEXT_FIELDS = [
20
+ "stop_last_error",
21
+ "error_message",
22
+ "last_error",
23
+ "stage_detail",
24
+ ];
25
+ import { resolveLaunchGates } from "./config.js";
26
+ import { VERSION } from "./version.js";
27
+ export { VERSION };
28
+ /**
29
+ * Wrap a tool handler so hooks.onToolCall observes the tool name, success
30
+ * flag, elapsed milliseconds, and on failure the error message string only
31
+ * (never tool arguments). The wrapper does not change what the handler
32
+ * returns or throws; without a hook the handler is registered untouched.
33
+ */
34
+ function wrapToolHandler(name, handler, onToolCall) {
35
+ if (!onToolCall)
36
+ return handler;
37
+ const fn = handler;
38
+ const wrapped = async (...args) => {
39
+ const start = Date.now();
40
+ try {
41
+ const out = await fn(...args);
42
+ onToolCall({ tool: name, ok: true, ms: Date.now() - start });
43
+ return out;
44
+ }
45
+ catch (error) {
46
+ onToolCall({
47
+ tool: name,
48
+ ok: false,
49
+ ms: Date.now() - start,
50
+ error: error instanceof Error ? error.message : String(error),
51
+ });
52
+ throw error;
53
+ }
54
+ };
55
+ return wrapped;
56
+ }
57
+ const SAFE_ID_RE = /^[a-zA-Z0-9_-]+$/;
58
+ function validateId(value, label) {
59
+ if (!SAFE_ID_RE.test(value)) {
60
+ throw new Error(`Invalid ${label}: must contain only alphanumeric characters, hyphens, and underscores`);
61
+ }
62
+ }
63
+ function result(text) {
64
+ return { content: [{ type: "text", text }] };
65
+ }
66
+ function json(data) {
67
+ // A tool result of literally `null` is unreadable: an agent cannot tell an
68
+ // empty answer from a failure, and "null" is the one string that invites a
69
+ // wrong conclusion either way. The client already normalizes empty bodies;
70
+ // this is the last-resort guard for any other path.
71
+ if (data === null || data === undefined) {
72
+ return result(JSON.stringify({
73
+ code: "EMPTY_RESPONSE",
74
+ message: "The Run BiOS API answered this read with no JSON body. This is not an error verdict and nothing was changed.",
75
+ instruction: "Retry once; if it repeats, confirm the resource with the matching list/status tool before drawing a conclusion.",
76
+ }, null, 2));
77
+ }
78
+ return result(JSON.stringify(data, null, 2));
79
+ }
80
+ /* ─── Honest, actionable recovery text ───
81
+ * An MCP client ACTS on this text, so recovery advice must not dead-end on a
82
+ * single tool: list_models and search_models read the same registry through the
83
+ * same dependency, so one outage takes both down. Name every route the agent
84
+ * has, and tell it what to do when none of them answer, instead of pointing at
85
+ * one tool that may be unavailable. */
86
+ const CATALOG_RECOVERY = "Pick an id from the Run BiOS catalog: call list_models (everything hosted) or search_models (filter by name/provider) — "
87
+ + "either one answers from the same registry. If BOTH are unavailable, do NOT guess another id: any id already returned "
88
+ + "by list_inferences or list_training_jobs is known-hosted, the same catalog is on the Models page of the Run BiOS console, "
89
+ + "and get_platform_guide (topic \"models\") states the rule. When nothing can confirm an id, report the catalog as "
90
+ + "unavailable instead of retrying with an unverified one.";
91
+ /* ─── Context-length policy (owner-mandated, enforced server-side) ───
92
+ * The window is DERIVED unless the caller overrides it: the default is
93
+ * min(model native max, 262144), a model whose native window is unknown falls
94
+ * back to 32768, a genuinely small-context model keeps its own smaller window,
95
+ * and the hard ceiling is always the model's OWN native max — the server
96
+ * rejects anything above it. No static JSON Schema can express a per-model
97
+ * ceiling, so the bounds below are an honest sanity range and the description
98
+ * names the real authority (model.native_max_context from preflight). */
99
+ const CONTEXT_DEFAULT_CEILING = 262_144;
100
+ const CONTEXT_UNKNOWN_FALLBACK = 32_768;
101
+ const CONTEXT_TOOL_MIN = 1_024;
102
+ const CONTEXT_TOOL_MAX = 1_048_576;
103
+ const CONTEXT_POLICY_TEXT = `Omit it to accept the platform default: min(model native max, ${CONTEXT_DEFAULT_CEILING}) tokens, `
104
+ + `falling back to ${CONTEXT_UNKNOWN_FALLBACK} when the model's native window is unknown (a model whose own window is `
105
+ + `smaller keeps its own). The hard ceiling is the model's OWN native window — a larger value is rejected. `
106
+ + `Read model.native_max_context from preflight_inference before proposing a value; `
107
+ + `the ${CONTEXT_TOOL_MIN}-${CONTEXT_TOOL_MAX} range accepted here is only a sanity bound, never a per-model ceiling.`;
108
+ /**
109
+ * Context-length input with truthful bounds and self-contained rejection text.
110
+ * The MCP SDK renders a zod failure as its raw issue list, so the message on
111
+ * each bound has to carry the whole answer by itself — otherwise the caller
112
+ * gets a schema dump with no instruction.
113
+ */
114
+ /* ─── Precision ───
115
+ * Precision is a sizing input, so it is a PRICE input: it sets the VRAM
116
+ * estimate, which sets the minimum GPU count, which sets the hourly total. An
117
+ * agent that writes "FP8" is asking for fp8, and must not be quoted the bf16
118
+ * price for it. Canonicalize case and whitespace before validating, then refuse
119
+ * anything outside the vocabulary BY NAME. bf16/fp16 are accepted spellings of
120
+ * "serve the checkpoint at its own precision", which is what none means.
121
+ */
122
+ const QUANT_FORMATS = ["fp8", "awq", "gptq", "int4"];
123
+ const NATIVE_PRECISION_ALIASES = ["none", "bf16", "bfloat16", "fp16", "float16"];
124
+ function quantSchema(purpose) {
125
+ return z
126
+ .string()
127
+ .transform((v) => v.trim().toLowerCase())
128
+ .refine((v) => NATIVE_PRECISION_ALIASES.includes(v)
129
+ || QUANT_FORMATS.includes(v), {
130
+ error: (issue) => `${String(issue.input)} is not a precision this platform can serve. `
131
+ + `Use one of: ${QUANT_FORMATS.join(", ")}, or none.`,
132
+ })
133
+ .transform((v) => (NATIVE_PRECISION_ALIASES.includes(v) ? "none" : v))
134
+ .optional()
135
+ .describe(`${purpose} One of ${QUANT_FORMATS.join(", ")} or none. Case does not matter, `
136
+ + "and bf16/fp16 mean the same as none. Anything else is refused by name rather "
137
+ + "than sized at another precision.");
138
+ }
139
+ function contextLengthSchema(purpose) {
140
+ return z
141
+ .number()
142
+ .int({ error: "context_length must be a whole number of tokens." })
143
+ .min(CONTEXT_TOOL_MIN, {
144
+ error: (issue) => `context_length ${String(issue.input)} is below ${CONTEXT_TOOL_MIN} tokens, which cannot serve a request. `
145
+ + CONTEXT_POLICY_TEXT,
146
+ })
147
+ .max(CONTEXT_TOOL_MAX, {
148
+ error: (issue) => `context_length ${String(issue.input)} is above the ${CONTEXT_TOOL_MAX}-token sanity bound of this tool. `
149
+ + CONTEXT_POLICY_TEXT,
150
+ })
151
+ .optional()
152
+ .describe(`${purpose} ${CONTEXT_POLICY_TEXT}`);
153
+ }
154
+ /* ─── Spend-consent bounds (the one field that authorizes money) ───
155
+ * max_price_hour_cents is CONSENT, not a filter: it is the maximum total hourly
156
+ * price for the complete GPU count that the platform may charge without asking
157
+ * the user again. The server has no ceiling of its own — it only refuses a cap
158
+ * BELOW the primary placement's verified price (deployment-service
159
+ * validateInferenceGPUPriceContract, training-service validateTrainingRequest)
160
+ * — so `z.number().int()` alone advertised MAX_SAFE_INTEGER, i.e. told an agent
161
+ * that consenting to nine quadrillion cents an hour is a legal request. The
162
+ * ceiling below comes from what the platform can actually book: the most
163
+ * expensive placement in the GPU catalog is 8 x B300 at 1035 cents per GPU-hour
164
+ * = 8280 cents/hour total, and the beta serving cap (models up to ~130B total
165
+ * parameters) keeps a deployment inside that ladder. 100000 cents ($1,000/hour)
166
+ * is roughly twelve times the dearest bookable configuration: high enough that
167
+ * it can never reject a real cap, low enough that it is a limit rather than a
168
+ * blank cheque. It is a sanity bound, not a price quote — the authority is
169
+ * always the preflight response. */
170
+ const PRICE_CAP_TOOL_MAX = 100_000;
171
+ const priceCapPolicyText = (floor) => "This is SPEND CONSENT, not a filter: the maximum accepted TOTAL hourly price for the complete GPU count, in cents. "
172
+ + "Copy the cap the user approved from the preflight response (billing.max_price_hour_cents, or the chosen placement's "
173
+ + "total_price_hour_cents) — never invent one. The server rejects a cap BELOW the primary placement's current price, and "
174
+ + `omitting it accepts the verified price of the placements already approved. The ${floor}-${PRICE_CAP_TOOL_MAX} range here `
175
+ + "is a sanity bound on consent (the dearest configuration the GPU catalog can book is well under it), not a price quote and "
176
+ + "not a per-model ceiling; get_gpu_pricing and preflight give the real numbers.";
177
+ /**
178
+ * Price-cap input with a defensible ceiling and self-contained rejection text.
179
+ * `allowZero` covers the training tools, where 0 is the documented "no explicit
180
+ * cap — accept the verified placement price" value; the deployment tools require
181
+ * a positive cap (deployment-contract.ts rejects <= 0).
182
+ */
183
+ function priceCapSchema(allowZero) {
184
+ const floor = allowZero ? 0 : 1;
185
+ const policy = priceCapPolicyText(floor);
186
+ return z
187
+ .number()
188
+ .int({ error: "max_price_hour_cents must be a whole number of cents." })
189
+ .min(floor, {
190
+ error: (issue) => `max_price_hour_cents ${String(issue.input)} is below ${floor}. `
191
+ + (allowZero
192
+ ? "A cap cannot be negative; pass 0 (or omit it) to accept the verified placement price. "
193
+ : "Omit it instead of passing 0 or a negative number to accept the verified placement price. ")
194
+ + policy,
195
+ })
196
+ .max(PRICE_CAP_TOOL_MAX, {
197
+ error: (issue) => `max_price_hour_cents ${String(issue.input)} is above the ${PRICE_CAP_TOOL_MAX}-cent/hour ($1,000/hour) sanity bound `
198
+ + "of this tool. A cap that large is not a limit — it authorizes any price the platform could ever charge. "
199
+ + policy,
200
+ })
201
+ .optional()
202
+ .describe(policy);
203
+ }
204
+ /* ─── Training context window (max_seq_length) ───
205
+ * Same defect as context_length before #789: `max(10_000_000)` is not a policy,
206
+ * it is a number nothing enforces. The authoritative contract is
207
+ * get_training_capabilities, whose `max_length` field states the platform's
208
+ * default (2048) and its floor (128); the real ceiling is the base model's own
209
+ * position-embedding window, which no static JSON Schema can express. So the
210
+ * schema advertises the contract's floor and the same sanity ceiling serving
211
+ * uses, and the description names the real authority. */
212
+ const TRAINING_SEQ_TOOL_MIN = 128;
213
+ const TRAINING_SEQ_DEFAULT = 2_048;
214
+ const TRAINING_SEQ_POLICY_TEXT = `Training sequence length in tokens (sent as config.max_length). Omit it to accept the platform default of ${TRAINING_SEQ_DEFAULT}. `
215
+ + "The hard ceiling is the BASE MODEL's own context window — a longer value wastes GPU memory on padding and can fail the fit "
216
+ + `check — and the authoritative field bounds live in get_training_capabilities (max_length). The ${TRAINING_SEQ_TOOL_MIN}-`
217
+ + `${CONTEXT_TOOL_MAX} range accepted here is only a sanity bound, never a per-model ceiling: read the model's window from `
218
+ + "get_model_config or preflight_training_job before proposing a value. Longer sequences raise memory use roughly linearly, "
219
+ + "so raise it deliberately, not by default.";
220
+ const MAX_TOKENS_POLICY_TEXT = "Maximum tokens to GENERATE for this completion. Omit it to let the endpoint choose. The real ceiling is the deployment's "
221
+ + `served context window MINUS the prompt (read context_length from get_inference_status or the create response); the 1-`
222
+ + `${CONTEXT_TOOL_MAX} range accepted here is only a sanity bound, never the window of the model being called. Tokens are `
223
+ + "billed, so ask for what the answer needs rather than the maximum.";
224
+ function trainingSeqLengthSchema() {
225
+ return z
226
+ .number()
227
+ .int({ error: "max_seq_length must be a whole number of tokens." })
228
+ .min(TRAINING_SEQ_TOOL_MIN, {
229
+ error: (issue) => `max_seq_length ${String(issue.input)} is below the ${TRAINING_SEQ_TOOL_MIN}-token floor of the training contract. `
230
+ + TRAINING_SEQ_POLICY_TEXT,
231
+ })
232
+ .max(CONTEXT_TOOL_MAX, {
233
+ error: (issue) => `max_seq_length ${String(issue.input)} is above the ${CONTEXT_TOOL_MAX}-token sanity bound of this tool. `
234
+ + TRAINING_SEQ_POLICY_TEXT,
235
+ })
236
+ .optional()
237
+ .describe(TRAINING_SEQ_POLICY_TEXT);
238
+ }
239
+ // Reuse this exact schema for preflight and create. The helper beneath it
240
+ // performs the source-dependent checks that a flat MCP input schema cannot.
241
+ const deploymentCreateToolSchema = {
242
+ name: z.string().min(1).max(255).describe("Deployment display name."),
243
+ source_type: z
244
+ .enum(DEPLOYMENT_SOURCE_TYPES)
245
+ .describe("Deploy a verified training checkpoint or a base model from the catalog."),
246
+ source_job_id: z
247
+ .string()
248
+ .optional()
249
+ .describe("Owning training job ID; required with source_type=checkpoint."),
250
+ source_checkpoint_id: z
251
+ .string()
252
+ .optional()
253
+ .describe("Verified checkpoint ID; required with source_type=checkpoint."),
254
+ hf_model_id: z
255
+ .string()
256
+ .optional()
257
+ .describe("Catalog model id (see list_models); required when deploying a hosted base model (source_type=hf_model, the legacy wire name). Must be hosted on Run BiOS."),
258
+ hf_model_revision: z
259
+ .string()
260
+ .regex(/^[0-9a-f]{40}$/)
261
+ .optional()
262
+ .describe("Exact model commit from preflight_inference canonical_request. Copy it unchanged into create_inference."),
263
+ hf_integration_id: z
264
+ .string()
265
+ .optional()
266
+ .describe("Legacy field kept for compatibility — catalog models are pre-mirrored and never gated, so no integration is needed. Raw tokens are not accepted by MCP."),
267
+ base_model_id: z
268
+ .string()
269
+ .optional()
270
+ .describe("Base model for an adapter checkpoint; normally resolved from training lineage."),
271
+ base_model_revision: z
272
+ .string()
273
+ .regex(/^[0-9a-f]{40}$/)
274
+ .optional()
275
+ .describe("Exact base-model commit from preflight_inference canonical_request. Copy it unchanged into create_inference."),
276
+ // serving_mode, model_task and supports_images are DELIBERATELY ABSENT.
277
+ // All three are derived server-side and immutable: the serving mode comes
278
+ // from the verified checkpoint's lineage, the OpenAI task from the resolved
279
+ // model (chat, or completion for a base model with no chat template, or
280
+ // embedding/rerank for those architectures), and image support from the
281
+ // model's own config. Advertising them as inputs invited exactly the 400s the
282
+ // create gate now returns ("configured automatically from the model itself
283
+ // ... and cannot be changed"). They are reported back on the
284
+ // preflight/create/status response, which is where a caller reads them.
285
+ gpu_type: z
286
+ .string()
287
+ .min(1)
288
+ .describe("Exact GPU SKU approved after reviewing authoritative deployment options."),
289
+ gpu_count: z
290
+ .number()
291
+ .int()
292
+ .min(1)
293
+ .max(8)
294
+ .describe("Tensor-parallel GPU count accepted by the model-fit option."),
295
+ gpu_priorities: z
296
+ .array(z.object({
297
+ gpu_type: z.string().min(1), gpu_count: z.number().int().min(1).max(8),
298
+ provider: z.string().min(1).max(30).optional(), region: z.string().min(1).max(64).optional(),
299
+ tier: z.literal("secure").optional(),
300
+ }))
301
+ .min(1)
302
+ .max(5)
303
+ .optional()
304
+ .describe("Ranked GPU placement choices. Copy these unchanged from the preflight response's accepted choices; the platform fills in and manages infrastructure placement automatically. Queueing requires 3-5 distinct entries; entry one must match gpu_type/gpu_count."),
305
+ gpu_tier: z
306
+ .enum(DEPLOYMENT_GPU_TIERS)
307
+ .optional()
308
+ .describe("Deployment serving supports secure capacity only; defaults to secure."),
309
+ allow_capacity_queue: z
310
+ .boolean()
311
+ .optional()
312
+ .describe("Explicit consent to wait up to seven days if this exact SKU is unavailable; defaults to false."),
313
+ max_price_hour_cents: priceCapSchema(false),
314
+ storage_gb: z.number().int().min(1).max(10000).optional(),
315
+ context_length: contextLengthSchema("Serving context window in tokens."),
316
+ quant: quantSchema("Runtime precision. Pre-quantized checkpoint precision may be locked."),
317
+ serving_config: z
318
+ .record(z.string(), z.unknown())
319
+ .optional()
320
+ .describe("Advanced serving arguments; the server applies its strict allowlist."),
321
+ };
322
+ const deploymentCreateMutationToolSchema = {
323
+ ...deploymentCreateToolSchema,
324
+ idempotency_key: z
325
+ .string()
326
+ .regex(/^[A-Za-z0-9._:-]{8,128}$/)
327
+ .describe("Stable retry key. Reuse the same value with the unchanged payload after timeouts to recover the same deployment and one-time inference key."),
328
+ };
329
+ /* ─── Response shaping that keeps provenance honest ─── */
330
+ function asRecord(value) {
331
+ return value && typeof value === "object" && !Array.isArray(value)
332
+ ? value
333
+ : undefined;
334
+ }
335
+ function nonEmptyString(value) {
336
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
337
+ }
338
+ /* ─── Internal build references never leave the platform ─── */
339
+ /**
340
+ * Recursively redact every string in a JSON-shaped value, counting the fields
341
+ * that carried something.
342
+ *
343
+ * Redacting by KEY was defeated the moment the same value appeared under a
344
+ * different key: dropping `image_tag` left the identical
345
+ * `<registry>/<engine>@sha256:<digest>` in the free-text `notes` of 194
346
+ * training-scope rows, and the inference scope has the same shape waiting in
347
+ * the "dropped by engine <tag>" note its auto-disable pass writes. So the rule
348
+ * is content, not field name, and it runs over every string in the payload.
349
+ *
350
+ * The matching itself lives in redaction.ts and is NOT repeated here. An
351
+ * earlier revision of this module carried its own pattern list anchored on a
352
+ * plaintext array of the engine repositories Run BiOS builds — and `npm publish`
353
+ * ships dist/, so that array handed every customer the exact roster the
354
+ * redactor exists to protect (test/build-identity-leak.test.ts pins this, and
355
+ * the console hit the identical defect in its own leak guard). redaction.ts
356
+ * matches the same references by SHAPE plus salted hashes of the names, so it
357
+ * is strictly stronger AND publishes nothing.
358
+ */
359
+ function redactDeep(value, counter) {
360
+ if (typeof value === "string") {
361
+ const out = redactBuildIdentity(value);
362
+ if (out !== value)
363
+ counter.hits += 1;
364
+ return out;
365
+ }
366
+ if (Array.isArray(value))
367
+ return value.map((entry) => redactDeep(entry, counter));
368
+ const record = asRecord(value);
369
+ if (!record)
370
+ return value;
371
+ const out = {};
372
+ for (const [key, entry] of Object.entries(record))
373
+ out[key] = redactDeep(entry, counter);
374
+ return out;
375
+ }
376
+ /**
377
+ * Label the architecture registry's provenance instead of shipping it bare.
378
+ *
379
+ * Each row in the registry TABLE records image_tag/bios_version/manifest_sha256
380
+ * from the serving image whose capability manifest last published it. That is
381
+ * REGISTRY PROVENANCE, not the image the platform is running now, and the two
382
+ * drift apart whenever a newer image has not re-published the manifest. An
383
+ * agent that reads a build reference next to a support list concludes "the
384
+ * platform runs this build" and tells the user a stale version — a wrong action
385
+ * caused purely by presentation. The support decision itself IS authoritative
386
+ * (the deploy gate reads these same rows), so the rows are preserved; the
387
+ * internal image reference is removed by CONTENT from every field of every
388
+ * scope (see redactBuildReferences) and replaced with an explicit provenance
389
+ * block. Fixing the underlying manifest staleness is a control-plane concern
390
+ * and is deliberately NOT duplicated here.
391
+ *
392
+ * Those three columns no longer REACH this function: the public endpoint
393
+ * projects them away before the response leaves the control plane. What arrives
394
+ * here is the customer projection, and this block must describe that.
395
+ *
396
+ * The block itself only asserts what the rows actually carry, and since the
397
+ * control plane started projecting this endpoint that is LESS than it once was:
398
+ * image_tag, bios_version and manifest_sha256 are dropped at the boundary now
399
+ * (publicArchRow in deployment-service), so no response can still see them.
400
+ * Counting them here would therefore report 0 forever and drive the block to
401
+ * announce "no row records a build version" about a registry whose rows do —
402
+ * narrating a field that is empty on every row is the same defect one level up,
403
+ * and asserting a fact about rows this layer can no longer inspect is worse.
404
+ * `source` is the discriminator that survives the projection, so it is the one
405
+ * this block counts on.
406
+ */
407
+ export function annotateArchitectureProvenance(data) {
408
+ const payload = asRecord(data);
409
+ if (!payload || !Array.isArray(payload.architectures))
410
+ return data;
411
+ const counter = { hits: 0 };
412
+ let manifestRows = 0;
413
+ let lastPublished = "";
414
+ const architectures = payload.architectures.map((entry) => {
415
+ const row = asRecord(entry);
416
+ if (!row)
417
+ return redactDeep(entry, counter);
418
+ // Belt and braces: the boundary already omits image_tag, so this destructure
419
+ // only matters if the projection ever regresses. It is cheap and it is the
420
+ // reason this file carries a REVIEWED entry in the leak-gate allowlist —
421
+ // deleting it means deleting that entry too, or the gate fails on a stale one.
422
+ const { image_tag: _internalImageReference, ...rest } = row;
423
+ if (nonEmptyString(row.source) === "manifest")
424
+ manifestRows += 1;
425
+ const updated = nonEmptyString(row.updated_at);
426
+ if (updated && updated > lastPublished)
427
+ lastPublished = updated;
428
+ return redactDeep(rest, counter);
429
+ });
430
+ // Every OTHER field of the envelope goes through the same content rule: the
431
+ // whole point is that no field name is trusted, including one the control
432
+ // plane has not added yet.
433
+ const { architectures: _rows, ...envelope } = payload;
434
+ return {
435
+ ...redactDeep(envelope, counter),
436
+ architectures,
437
+ manifest_provenance: {
438
+ note: "Architecture support and the enabled flags are authoritative — the deployment gate reads these same rows. "
439
+ + "Some rows were published by an internal build's capability manifest, and the count below says how many. WHICH "
440
+ + "build is not part of this response at all: the platform does not publish it on this endpoint, so nothing here "
441
+ + "names or numbers an engine build. Do not report a platform version from this response, and do not infer from "
442
+ + "the absence that the registry is stale or current — this response cannot tell you either way. "
443
+ + "Build identity is removed by key AND by content from every field, so nothing here names the engine the platform runs.",
444
+ // The COUNTS are the honest signal; the VALUES are not. An earlier revision
445
+ // of this block also listed `recorded_bios_versions` — the distinct engine
446
+ // build versions read off the rows — which handed the caller the exact
447
+ // build identity every other rule here removes. Counting says as much about
448
+ // staleness and names nothing. Its siblings that counted build versions and
449
+ // manifest digests are gone for the opposite reason: after the boundary
450
+ // projection they could only ever have counted 0.
451
+ rows: architectures.length,
452
+ rows_from_a_published_manifest: manifestRows,
453
+ last_row_update: lastPublished || null,
454
+ // Counts FIELDS that carried a reference, not individual references: the
455
+ // matcher rewrites a whole string in one pass, so a per-occurrence tally
456
+ // would be a guess. Named for what it actually measures.
457
+ fields_with_build_references_redacted: counter.hits,
458
+ },
459
+ };
460
+ }
461
+ /** Say what the reported GPU means in the deployment's current lifecycle state. */
462
+ function gpuState(status) {
463
+ switch (status) {
464
+ case "queued_capacity":
465
+ return "not held yet — waiting for stock, nothing charged";
466
+ case "running":
467
+ case "degraded":
468
+ return "held and serving";
469
+ case "provisioning":
470
+ case "downloading_weights":
471
+ case "loading_model":
472
+ return "being booked or booting — not serving yet";
473
+ case "stopped":
474
+ case "paused_insufficient_funds":
475
+ case "failed":
476
+ case "crash_loop":
477
+ case "deleting":
478
+ case "deleted":
479
+ return "not held — this is the SKU the deployment would resume on";
480
+ default:
481
+ return "unknown";
482
+ }
483
+ }
484
+ /**
485
+ * Summarize what a deployment detail payload ACTUALLY proves about the GPU and
486
+ * the lifecycle phase.
487
+ *
488
+ * The detail response is wide and reports several things as null until a pod
489
+ * callback lands, which reads to an agent as "no GPU" and "no phase" on a
490
+ * deployment that is demonstrably provisioning on a known SKU. Everything here
491
+ * is derived from fields the same payload returned — nothing is invented, and a
492
+ * value that cannot be derived stays "unknown" instead of being guessed.
493
+ */
494
+ export function summarizeInferenceStatus(data) {
495
+ const payload = asRecord(data);
496
+ if (!payload)
497
+ return data;
498
+ const status = nonEmptyString(payload.status) ?? "unknown";
499
+ const gpuType = nonEmptyString(payload.gpu_type);
500
+ const gpuCount = typeof payload.gpu_count === "number" ? payload.gpu_count : undefined;
501
+ const gpu = gpuType ? `${gpuCount && gpuCount > 0 ? gpuCount : 1}x ${gpuType}` : "unknown";
502
+ const download = asRecord(payload.download_progress);
503
+ const boot = asRecord(payload.boot_progress);
504
+ // Phase precedence: the agent's own weight/load report is the most advanced
505
+ // signal, then the pre-agent boot report, then the durable lifecycle status.
506
+ const reportedPhase = nonEmptyString(download?.phase) ?? nonEmptyString(boot?.phase);
507
+ const phaseSource = nonEmptyString(download?.phase)
508
+ ? "agent_progress_report"
509
+ : nonEmptyString(boot?.phase)
510
+ ? "pre_agent_boot_report"
511
+ : "deployment_status";
512
+ const percent = typeof download?.percent === "number"
513
+ ? download.percent
514
+ : typeof boot?.percent === "number" ? boot.percent : null;
515
+ const endpoint = nonEmptyString(payload.endpoint_url);
516
+ const summary = {
517
+ status,
518
+ phase: reportedPhase ?? status,
519
+ phase_source: phaseSource,
520
+ phase_percent: percent,
521
+ gpu,
522
+ gpu_tier: nonEmptyString(payload.gpu_tier) ?? "unknown",
523
+ // What the GPU above MEANS right now: a queued or provisioning deployment
524
+ // does not hold the SKU yet, it is the one being waited/booked for.
525
+ gpu_state: gpuState(status),
526
+ endpoint_usable: status === "running" && Boolean(endpoint),
527
+ desired_state: nonEmptyString(payload.desired_state) ?? "unknown",
528
+ error_code: nonEmptyString(payload.error_code) ?? nonEmptyString(payload.status_reason) ?? null,
529
+ note: "Derived by the MCP server from the fields in this same response; the raw fields above are unchanged. "
530
+ + "Report the endpoint as live only when endpoint_usable is true.",
531
+ };
532
+ const shaped = { ...payload, status_summary: summary };
533
+ // A present-but-null `gpu`/`phase` reads as "there is none" even when the row
534
+ // proves otherwise. Fill from the derived value and say so, rather than
535
+ // passing a null the agent will misread.
536
+ for (const key of ["gpu", "phase"]) {
537
+ if (key in shaped && shaped[key] === null && summary[key] !== "unknown") {
538
+ shaped[key] = summary[key];
539
+ summary.filled_null_fields = [...(summary.filled_null_fields ?? []), key];
540
+ }
541
+ }
542
+ return shaped;
543
+ }
544
+ /* ─── Server setup ─── */
545
+ export function createBiosMcpServer(client, hooks, deploymentCaps,
546
+ // Pre-launch surface gates (src/config.ts). While a gate is on, its creation
547
+ // tools are NOT REGISTERED at all — hidden from tools/list so an agent never
548
+ // learns they exist — and the platform guide stops teaching their workflows.
549
+ // Defaults read the environment so a deployment with nothing set fails CLOSED
550
+ // (gated) while pre-launch; tests that exercise creation pass
551
+ // { training: false, datasets: false }.
552
+ launchGates = resolveLaunchGates()) {
553
+ // `instructions` reaches the client at initialize, BEFORE any tool description
554
+ // is read, which is the only place to state the rules a tool schema cannot: an
555
+ // agent that learns the derived-settings and catalog rules up front never
556
+ // proposes a value the control plane must reject.
557
+ // While a creation surface is gated, the instructions must not present it as
558
+ // available: an agent reads these BEFORE any tool list, and a capability named
559
+ // here is a capability it will try. The gated wording names what is live and
560
+ // states — once, without naming tool names — that creation is not offered.
561
+ const gatedSurfaces = [
562
+ ...(launchGates.training ? ["fine-tuning"] : []),
563
+ ...(launchGates.datasets ? ["dataset upload/import"] : []),
564
+ ];
565
+ const prelaunchNote = gatedSurfaces.length
566
+ ? `${gatedSurfaces.join(" and ")} ${gatedSurfaces.length > 1 ? "are" : "is"} not available in this deployment yet — `
567
+ + "no creation tools for them are offered, so do not attempt to start one or look for a workaround. "
568
+ + "Existing jobs and datasets stay readable.\n"
569
+ : "";
570
+ const mcp = new McpServer({
571
+ name: "Run BiOS",
572
+ version: VERSION,
573
+ }, {
574
+ instructions: (launchGates.training
575
+ ? "Run BiOS serves models on its own GPU control plane. Call get_platform_guide first.\n"
576
+ : "Run BiOS trains and serves models on its own GPU control plane. Call get_platform_guide first.\n")
577
+ + prelaunchNote
578
+ + "Rules that no tool schema can express:\n"
579
+ + (launchGates.training
580
+ ? "1. Models: only ids in the Run BiOS catalog can be served (list_models / search_models). "
581
+ : "1. Models: only ids in the Run BiOS catalog can be trained or served (list_models / search_models). ")
582
+ + "This is not a Hugging Face lookup; an id outside the catalog is rejected, never mirrored on demand.\n"
583
+ + "2. Serving is platform-derived: serving mode (full/adapter/merged), the OpenAI task "
584
+ + "(chat, or completion for a base model with no chat template, or embedding/rerank), and image support are "
585
+ + "derived from the model and checkpoint lineage. They are not tool inputs — read them from the "
586
+ + "preflight/create/status response.\n"
587
+ + `3. Context window: the default is min(model native max, ${CONTEXT_DEFAULT_CEILING}) tokens, falling back to `
588
+ + `${CONTEXT_UNKNOWN_FALLBACK} when the native window is unknown, and the hard ceiling is the model's own native max. `
589
+ + "Read model.native_max_context from preflight_inference before proposing a value.\n"
590
+ + "4. GPUs and money: never substitute a GPU or exceed an approved price cap without asking. A capacity "
591
+ + "rejection arrives as compact JSON with bookable alternatives; a 503 is a transient outage, never an out-of-stock verdict.\n"
592
+ + "5. Report an endpoint as live only when the status says running.",
593
+ });
594
+ // The registrations below are moved verbatim from the original single-file
595
+ // entry and intentionally keep their original indentation. `server.tool(...)`
596
+ // is a thin delegate that adds the onToolCall hook around each handler.
597
+ const server = {
598
+ tool(name, description, paramsSchema, cb) {
599
+ mcp.tool(name, description, paramsSchema, wrapToolHandler(name, cb, hooks?.onToolCall));
600
+ },
601
+ };
602
+ /* ══════════════════════════════════════════════════════════════════════════ */
603
+ /* TOOL: get_platform_guide */
604
+ /* ══════════════════════════════════════════════════════════════════════════ */
605
+ server.tool("get_platform_guide", launchGates.training || launchGates.datasets
606
+ ? "Get a comprehensive guide to the Run BiOS platform. Call this FIRST to understand the available capabilities, the correct workflow order, supported models, GPU tiers, and best practices for serving models."
607
+ : "Get a comprehensive guide explaining how to use the Run BiOS fine-tuning platform. Call this FIRST to understand the available capabilities, the correct workflow order, supported models, training methods, GPU tiers, dataset formats, and best practices. This gives you all the context needed to help users fine-tune models effectively.", {
608
+ topic: z
609
+ .enum([
610
+ "overview",
611
+ "quick_start",
612
+ "models",
613
+ "datasets",
614
+ "training_methods",
615
+ "gpu_selection",
616
+ "inference",
617
+ "hyperparameters",
618
+ "monitoring",
619
+ "cost_optimization",
620
+ "capacity_errors",
621
+ ])
622
+ .optional()
623
+ .describe("Specific topic to learn about. Omit for the full platform overview."),
624
+ }, async ({ topic }) => {
625
+ /* PRE-LAUNCH GATES (src/config.ts): while a creation surface is gated, the
626
+ guide must not teach its workflow — an agent that reads "call
627
+ upload_dataset" will try a tool that is not registered. Gated topics get
628
+ a stand-in that says what launches soon and names only tools that ARE
629
+ registered (reads/lifecycle stay live). At launch the gates open and the
630
+ real topics return verbatim. */
631
+ // 42 tools with every creation tool registered; the gates hide three
632
+ // (create_training_job, upload_dataset, import_huggingface_dataset). The
633
+ // coming-soon gate test pins this claim to the wire count, so drift fails.
634
+ const liveToolCount = 42
635
+ - (launchGates.training ? 1 : 0)
636
+ - (launchGates.datasets ? 2 : 0);
637
+ const comingSoonNote = `# Coming soon
638
+
639
+ ${gatedSurfaces.join(" and ")} ${gatedSurfaces.length > 1 ? "are" : "is"} not available in this build yet — the creation tools are not registered, so no tool call (and no workaround) can start one today. Do not retry or search for one.
640
+
641
+ What still works: every read and lifecycle tool for existing datasets and training jobs (list_datasets, preview_dataset, delete_dataset, list_training_jobs, get_training_status, get_training_metrics, get_training_logs, get_training_evals, stop_training_job, resume_training_job, get_training_checkpoints, get_checkpoint_download_manifest, delete_training_checkpoint), and the full inference surface (preflight_inference, create_inference, get_inference_status, chat_with_inference).`;
642
+ const gatedOverview = `# Run BiOS Platform
643
+
644
+ Run BiOS serves models on its own GPU control plane: a curated catalog of verified models — mirrored in its own storage, never fetched live from Hugging Face — deployed to dedicated OpenAI-compatible endpoints.
645
+
646
+ ## Core workflow (serving, fully live)
647
+ 1. **Find a model** — search_models / list_models read the hosted catalog; these are the only models that can be deployed
648
+ 2. **Check GPU fit and price** — get_inference_gpu_options joins model-fit counts to live stock and total hourly prices
649
+ 3. **Preflight** — preflight_inference validates the exact request with no wallet, queue, database, or GPU side effects
650
+ 4. **Create the deployment** — create_inference books capacity book-before-reveal; a definitive miss returns CAPACITY_UNAVAILABLE and nothing exists
651
+ 5. **Run** — poll get_inference_status until status is running; call the endpoint with chat_with_inference
652
+ 6. **Control spend** — stop_inference / resume_inference / restart_inference / delete_inference
653
+
654
+ ## Launching soon — not offered in this build
655
+ ${gatedSurfaces.join(" and ")} ${gatedSurfaces.length > 1 ? "launch" : "launches"} soon: the creation tools are not registered, so no tool call — and no workaround — can start one today. Existing datasets and training jobs are unaffected: every read and lifecycle tool below still works.
656
+
657
+ ## What the PLATFORM decides for you (do not try to set these)
658
+ Serving settings are derived from the model itself and are immutable. They are
659
+ NOT tool inputs, and attempting to choose one is rejected:
660
+ - **Serving mode** (full / adapter / merged) comes from the verified checkpoint's
661
+ lineage; a catalog base model is always served whole.
662
+ - **OpenAI task** comes from the resolved model: an instruct/chat model and a
663
+ vision-language model serve as chat, a base model with no chat template serves
664
+ as text completion, and an embedding/reranker architecture serves as
665
+ embeddings/reranking. Every model in the current catalog resolves to chat.
666
+ - **Image input** comes from the model's own config, never from a caller claim.
667
+ - **Context window**: the default is min(model native max, 262144) tokens; a
668
+ model whose native window is unknown falls back to 32768, a model with a
669
+ smaller window keeps its own, and the hard ceiling is always the model's OWN
670
+ native max. You may pass a smaller context_length; a larger one is rejected.
671
+ Read model.native_max_context from preflight_inference before proposing a value.
672
+ Read the actual values back from preflight_inference, create_inference, and
673
+ get_inference_status instead of asserting them.
674
+
675
+ ## Billing
676
+ - Per-second GPU billing (NOT hourly) — you only pay for actual compute time
677
+ - Wallet-based prepaid system with auto top-up option
678
+ - New accounts get $10 welcome credit
679
+
680
+ ## Authentication
681
+ - API Key (recommended): Use \`X-API-Key: bios-...\` header — org and workspace are resolved automatically
682
+ - JWT token: Use \`Authorization: Bearer <token>\` header with \`X-Org-ID\` and \`X-Workspace-ID\` headers
683
+ - Call introspect_api_key to see your permissions, scopes, and which tools you can use
684
+
685
+ ## Available MCP Tools (${liveToolCount} in this build, listed in recommended order)
686
+ 1. get_platform_guide - You're reading this! Context for all capabilities
687
+ 2. introspect_api_key - Discover your API key's permissions, scopes, and bound workspace
688
+ 3. get_wallet_balance - Check your credits before creating a deployment
689
+ 4. search_models / list_models - Find a model from the hosted catalog
690
+ 5. get_model_config - Recommended settings for a chosen model
691
+ 6. list_supported_architectures - Which architectures can be served
692
+ 7. get_gpu_pricing / get_recommended_gpu - GPU options and pricing
693
+ 8. list_integrations - Connected Hugging Face accounts
694
+ 9. list_datasets / preview_dataset / delete_dataset - Read and clean up existing datasets
695
+ 10. get_training_capabilities - Read the authoritative training contract
696
+ 11. preflight_training_job - Validate a proposed job payload (no side effects)
697
+ 12. list_training_jobs / get_training_status - Monitor existing jobs
698
+ 13. get_training_metrics / get_training_evals / get_training_logs - Loss curves, benchmarks, logs
699
+ 14. stop_training_job / resume_training_job - Control existing jobs
700
+ 15. get_training_checkpoints / get_checkpoint_download_manifest / delete_training_checkpoint - Checkpoint access
701
+
702
+ ## Inference MCP Tools
703
+ 1. get_inference_gpu_options - Read model-fit choices joined to authoritative deployment stock and prices
704
+ 2. preflight_inference - Validate the exact request without wallet, queue, database, or GPU side effects
705
+ 3. create_inference - Create only after reviewing preflight alternatives, queue consent, and the price cap
706
+ 4. get_inference_booking - Poll a book-before-reveal booking handle from a long-running create_inference
707
+ 5. list_inferences / get_inference_status - Read durable lifecycle, queue expiry reason, and wallet-authorization state
708
+ 6. get_inference_metrics - Read request, token, latency, throughput, and GPU utilization metrics plus a recent time series
709
+ 7. get_inference_notifications - Inspect durable email delivery, retries, and dead letters
710
+ 8. update_inference_policy - Change explicit queue consent or the maximum accepted hourly price
711
+ 9. stop_inference / resume_inference / restart_inference - Control serving lifecycle
712
+ 10. chat_with_inference - Call a serverless catalog model by id or a dedicated deployment via the unified /v1 endpoint (optional streaming)
713
+ 11. delete_inference - Permanently remove a deployment after verified infrastructure teardown`;
714
+ const guides = {
715
+ overview: `# Run BiOS Fine-Tuning Platform
716
+
717
+ Run BiOS is a cloud fine-tuning platform for large language models. It hosts a curated catalog of verified base models — mirrored in its own storage, never fetched live from Hugging Face — with 15+ training methods and 6 alignment algorithms.
718
+
719
+ ## Core Workflow
720
+ 1. **Upload a dataset** — JSONL, Parquet, or CSV. The platform auto-validates format, detects columns, and maps fields.
721
+ 2. **Choose a base model** — Search the hosted Run BiOS catalog (search_models / list_models); every listed model is verified end-to-end and these are the only models that can be trained or deployed
722
+ 3. **Pick a training method** — SFT for instruction tuning, DPO/SimPO/ORPO/CPO/KTO for preference alignment
723
+ 4. **Select adapter** — LoRA (fast, efficient), QLoRA (lower VRAM), or Full fine-tune (max quality)
724
+ 5. **Configure hyperparameters** — learning rate, batch size, epochs, LoRA rank, etc. Smart defaults provided.
725
+ 6. **Choose a GPU** — A100 80GB, H100, A6000 etc. Platform recommends based on model size.
726
+ 7. **Launch and monitor** — Real-time loss curves, eval metrics, training logs, checkpoint saving
727
+ 8. **Download or deploy** — Get your fine-tuned model weights or merged checkpoints
728
+
729
+ ## What the PLATFORM decides for you (do not try to set these)
730
+ Serving settings are derived from the model itself and are immutable. They are
731
+ NOT tool inputs, and attempting to choose one is rejected:
732
+ - **Serving mode** (full / adapter / merged) comes from the verified checkpoint's
733
+ lineage; a catalog base model is always served whole.
734
+ - **OpenAI task** comes from the resolved model: an instruct/chat model and a
735
+ vision-language model serve as chat, a base model with no chat template serves
736
+ as text completion, and an embedding/reranker architecture serves as
737
+ embeddings/reranking. Every model in the current catalog resolves to chat.
738
+ - **Image input** comes from the model's own config, never from a caller claim.
739
+ - **Context window**: the default is min(model native max, 262144) tokens; a
740
+ model whose native window is unknown falls back to 32768, a model with a
741
+ smaller window keeps its own, and the hard ceiling is always the model's OWN
742
+ native max. You may pass a smaller context_length; a larger one is rejected.
743
+ Read model.native_max_context from preflight_inference before proposing a value.
744
+ Read the actual values back from preflight_inference, create_inference, and
745
+ get_inference_status instead of asserting them.
746
+
747
+ ## Billing
748
+ - Per-second GPU billing (NOT hourly) — you only pay for actual compute time
749
+ - Wallet-based prepaid system with auto top-up option
750
+ - New accounts get $10 welcome credit
751
+
752
+ ## Authentication
753
+ - API Key (recommended): Use \`X-API-Key: bios-...\` header — org and workspace are resolved automatically
754
+ - JWT token: Use \`Authorization: Bearer <token>\` header with \`X-Org-ID\` and \`X-Workspace-ID\` headers
755
+ - Call introspect_api_key to see your permissions, scopes, and which tools you can use
756
+
757
+ ## Available MCP Tools (42 in this build, listed in recommended order)
758
+ 1. get_platform_guide - You're reading this! Context for all capabilities
759
+ 2. introspect_api_key - Discover your API key's permissions, scopes, and bound workspace
760
+ 3. get_wallet_balance - Check if you have enough credits before training
761
+ 4. search_models / list_models - Find the right base model from the hosted catalog
762
+ 5. get_model_config - Get recommended settings for your chosen model
763
+ 6. list_supported_architectures - Check which architectures can be trained or served
764
+ 7. get_training_capabilities - Read the authoritative training contract; call this before building automated payloads
765
+ 8. get_gpu_pricing - See GPU options and pricing
766
+ 9. get_recommended_gpu - Get the best GPU for your model + adapter combo
767
+ 10. list_integrations - See the Hugging Face accounts connected to your workspace
768
+ 11. list_datasets / upload_dataset / import_huggingface_dataset - Prepare training data
769
+ 12. preview_dataset - Verify dataset format before training
770
+ 13. delete_dataset - Remove a dataset you no longer need
771
+ 14. preflight_training_job - Validate the full job before creating it
772
+ 15. create_training_job - Launch the fine-tuning job
773
+ 16. list_training_jobs / get_training_status - Monitor progress
774
+ 17. get_training_metrics - View loss curves and eval results
775
+ 18. get_training_evals - Review benchmark scores and quality assessments
776
+ 19. get_training_logs - Read training output logs
777
+ 20. stop_training_job / resume_training_job - Control running jobs
778
+ 21. get_training_checkpoints - Access saved model checkpoints
779
+ 22. get_checkpoint_download_manifest - Get short-lived per-file download URLs for a checkpoint
780
+ 23. delete_training_checkpoint - Remove a checkpoint you no longer need
781
+
782
+ ## Inference MCP Tools
783
+ 1. get_inference_gpu_options - Read model-fit choices joined to authoritative deployment stock and prices
784
+ 2. preflight_inference - Validate the exact request without wallet, queue, database, or GPU side effects
785
+ 3. create_inference - Create only after reviewing preflight alternatives, queue consent, and the price cap
786
+ 4. get_inference_booking - Poll a book-before-reveal booking handle from a long-running create_inference
787
+ 5. list_inferences / get_inference_status - Read durable lifecycle, queue expiry reason, and wallet-authorization state
788
+ 6. get_inference_metrics - Read request, token, latency, throughput, and GPU utilization metrics plus a recent time series
789
+ 7. get_inference_notifications - Inspect durable email delivery, retries, and dead letters
790
+ 8. update_inference_policy - Change explicit queue consent or the maximum accepted hourly price
791
+ 9. stop_inference / resume_inference / restart_inference - Control serving lifecycle
792
+ 10. chat_with_inference - Call a serverless catalog model by id or a dedicated deployment via the unified /v1 endpoint (optional streaming)
793
+ 11. delete_inference - Permanently remove a deployment after verified infrastructure teardown`,
794
+ quick_start: `# Quick Start Guide
795
+
796
+ ## Fastest path to a fine-tuned model:
797
+
798
+ ### Step 1: Check your balance
799
+ Call get_wallet_balance to confirm you have credits available.
800
+
801
+ ### Step 2: Search for a model
802
+ Call search_models with a query like "llama 8B" or "mistral 7B".
803
+ For beginners: start with meta-llama/Llama-3.1-8B-Instruct (good balance of quality and speed).
804
+
805
+ ### Step 3: Prepare your dataset
806
+ Option A: Upload your own file — call upload_dataset with the file path
807
+ Option B: Import from HuggingFace — call import_huggingface_dataset with a repo ID
808
+
809
+ Dataset must have at minimum an "instruction" and "output" column for SFT, or "chosen" and "rejected" for DPO/alignment.
810
+
811
+ ### Step 4: Get recommended config
812
+ Call get_model_config with your model name to see recommended GPU, adapter, and hyperparameters.
813
+
814
+ ### Step 5: Create the training job
815
+ Call create_training_job with:
816
+ - model: the model name from step 2
817
+ - dataset_id: the ID from step 3
818
+ - method: "sft" for instruction tuning (most common)
819
+ - adapter: "lora" (fastest, recommended for first jobs)
820
+ - Leave other params as defaults unless you have specific needs
821
+
822
+ ### Step 6: Monitor progress
823
+ create_training_job books a GPU before returning (~40s). A 'booked' status means the GPU is secured (booked==secured) and training is starting; 'securing' means it is still booking — poll get_training_status until 'booked'/'running'. A booking-time capacity miss returns CAPACITY_UNAVAILABLE and no job is created.
824
+ Call get_training_status periodically. Training typically takes 1-4 hours for small models.
825
+ If a started job loses its pod it rests at 'interrupted' (billing already stopped, not 'failed') — call resume_training_job to continue from the last checkpoint.
826
+ Call get_training_metrics to see loss curves — loss should decrease steadily.
827
+
828
+ ### Tips
829
+ - Start with LoRA, not full fine-tune — it's 10x faster and 5x cheaper
830
+ - 3 epochs is usually enough — more can overfit
831
+ - If loss plateaus early, try increasing learning rate slightly
832
+ - Use early stopping if loss increases for several steps`,
833
+ models: `# Model Catalog
834
+
835
+ Run BiOS serves a curated catalog of models hosted in its own storage — every
836
+ listed model is verified end-to-end for training and serving, and these are
837
+ the ONLY models that can be trained or deployed. The catalog is the source of
838
+ truth: call list_models for everything currently hosted, or search_models to
839
+ filter by name, provider, or size. A model that is not in the catalog is not
840
+ hosted yet — ask an admin to mirror it.
841
+
842
+ list_models and search_models read the same registry, so either one answers.
843
+ If BOTH are unavailable, do not guess an id: any id already returned by
844
+ list_inferences or list_training_jobs is known-hosted, the same catalog is on
845
+ the Models page of the console, and reporting the catalog as unavailable is the
846
+ correct answer. An unverified id fails the create gate anyway.
847
+
848
+ Hosted families typically include Llama, Mistral, Qwen, Gemma, and Phi across
849
+ small (1B-4B), medium (7B-13B), and large (30B-70B+) sizes, plus vision-
850
+ language models — but the live catalog always wins over any list.
851
+
852
+ ## Choosing a Model
853
+ - For general tasks: a 7B-8B instruct model (good balance of quality and cost)
854
+ - For coding: a code-specialized instruct model from the catalog
855
+ - For multilingual: Qwen models
856
+ - For low-resource: a 1B-4B mini model
857
+ - For maximum quality: a 70B-class model (requires multi-GPU)
858
+
859
+ Use search_models to find hosted models by name or provider.
860
+ Use get_model_config to see what training methods and GPUs work with a specific model.`,
861
+ datasets: `# Dataset Format Guide
862
+
863
+ ## Supported Formats
864
+ - **JSONL** (recommended) — One JSON object per line
865
+ - **Parquet** — Columnar format, efficient for large datasets
866
+ - **CSV** — Comma-separated, auto-detected headers
867
+
868
+ ## Required Columns by Training Method
869
+
870
+ ### SFT (Supervised Fine-Tuning)
871
+ At minimum: "instruction" + "output"
872
+ Recommended: "instruction" + "input" (optional context) + "output"
873
+ Chat format: "conversations" (list of {role, content} objects)
874
+
875
+ Example JSONL:
876
+ {"instruction": "Summarize this text", "input": "The quick brown fox...", "output": "A fox jumped over a dog."}
877
+ {"instruction": "Translate to French", "output": "Bonjour le monde"}
878
+
879
+ ### DPO / SimPO / ORPO / CPO / KTO (Alignment)
880
+ Required: "prompt" + "chosen" + "rejected"
881
+
882
+ Example JSONL:
883
+ {"prompt": "Explain quantum computing", "chosen": "Quantum computing uses qubits...", "rejected": "Quantum computing is magic..."}
884
+
885
+ ### CPT (Continued Pre-Training)
886
+ Required: "text" column
887
+
888
+ ### VLM (Vision-Language)
889
+ Required: "image" (path/URL) + "instruction" + "output"
890
+
891
+ ## Best Practices
892
+ - Minimum 100 rows recommended (1,000+ for best results)
893
+ - Maximum 500MB file size
894
+ - Clean, consistent formatting across all rows
895
+ - Remove duplicates and low-quality examples
896
+ - For SFT: diverse instruction types improve generalization
897
+ - For DPO: "chosen" and "rejected" should differ meaningfully
898
+
899
+ ## Column Mapping
900
+ The platform auto-detects standard column names. Non-standard names are mapped automatically:
901
+ - question/query/prompt → instruction
902
+ - answer/response/completion → output
903
+ - context/passage → input
904
+
905
+ ## INTEGRATIONS AND MULTIPLE HUGGING FACE ACCOUNTS
906
+ Workspaces can connect multiple Hugging Face accounts under Integrations. Each connection has an integration ID and a label. Call list_integrations to see the accounts connected to your workspace.
907
+
908
+ To choose which connected account an import uses:
909
+ - import_huggingface_dataset takes integration_id (required for imports)
910
+
911
+ Raw Hugging Face tokens are never accepted by MCP tools; only stored integration IDs are.
912
+ Rotating a token on the Integrations page heals every dataset linked to that integration automatically.`,
913
+ training_methods: `# Training Methods
914
+
915
+ ## SFT — Supervised Fine-Tuning ⭐ Most Common
916
+ Best for: Teaching a model to follow specific instructions or generate in a particular style.
917
+ Input: instruction + output pairs.
918
+ When to use: First fine-tuning job, domain-specific tasks, style transfer, format compliance.
919
+
920
+ ## DPO — Direct Preference Optimization
921
+ Best for: Aligning model outputs with human preferences without a reward model.
922
+ Input: prompt + chosen response + rejected response.
923
+ When to use: After SFT, to improve response quality based on preference data.
924
+
925
+ ## SimPO — Simple Preference Optimization
926
+ Best for: Simpler alternative to DPO with comparable results.
927
+ Input: Same as DPO (prompt + chosen + rejected).
928
+ When to use: When DPO training is unstable or you want faster convergence.
929
+
930
+ ## ORPO — Odds Ratio Preference Optimization
931
+ Best for: Combined SFT + preference alignment in a single training run.
932
+ Input: prompt + chosen + rejected.
933
+ When to use: When you want alignment without a separate SFT step first.
934
+
935
+ ## CPO — Contrastive Preference Optimization
936
+ Best for: Strong alignment with contrastive learning signal.
937
+ Input: prompt + chosen + rejected.
938
+ When to use: When you need stronger preference separation than DPO provides.
939
+
940
+ ## KTO — Kahneman-Tversky Optimization
941
+ Best for: Alignment with unpaired preference data (just good OR bad, not both).
942
+ Input: prompt + completion + label (good/bad).
943
+ When to use: When you have thumbs-up/thumbs-down data but not paired preferences.
944
+
945
+ ## CPT — Continued Pre-Training
946
+ Best for: Injecting domain knowledge into the base model before fine-tuning.
947
+ Input: Raw text documents.
948
+ When to use: Before SFT, when your domain has specialized vocabulary/knowledge.
949
+
950
+ ## VLM — Vision-Language Model Training
951
+ Best for: Fine-tuning multimodal models on image+text tasks.
952
+ Input: image + instruction + output.
953
+ When to use: Image captioning, visual QA, document understanding.
954
+
955
+ ## Recommended Pipeline
956
+ 1. CPT (if domain-specific knowledge needed) → 2. SFT → 3. DPO/SimPO (alignment)
957
+ For most users: SFT alone is sufficient.`,
958
+ gpu_selection: `# GPU Selection Guide
959
+
960
+ ## Available GPUs
961
+
962
+ | GPU | VRAM | Best For | Per-Second Cost |
963
+ |-----|------|----------|-----------------|
964
+ | A6000 | 48 GB | Models up to 13B with LoRA | ~$0.0007/sec |
965
+ | A100 80GB | 80 GB | Models up to 70B with QLoRA, up to 13B full | ~$0.0007/sec |
966
+ | H100 | 80 GB | Large models, fastest training | ~$0.0011/sec |
967
+
968
+ ## Model Size → GPU Recommendations
969
+
970
+ ### LoRA Fine-Tuning (most common)
971
+ - 1B-3B models: A6000 (48GB) — plenty of headroom
972
+ - 7B-13B models: A100 80GB ⭐ sweet spot
973
+ - 30B-70B models: A100 80GB or H100 (may need multi-GPU)
974
+
975
+ ### QLoRA Fine-Tuning (4-bit quantized, uses less VRAM)
976
+ - 7B-13B models: A6000 (48GB) — enough with quantization
977
+ - 30B-70B models: A100 80GB
978
+ - 70B+ models: H100 or multi-GPU A100
979
+
980
+ ### Full Fine-Tuning (no adapter, maximum quality)
981
+ - 1B-3B models: A100 80GB
982
+ - 7B models: 2-4x A100 80GB
983
+ - 13B+ models: 4-8x A100 or H100
984
+
985
+ ## Cost Optimization Tips
986
+ - Run BiOS bills per-SECOND, not per-hour — stop jobs early to save money
987
+ - LoRA training is 5-10x cheaper than full fine-tune for comparable quality
988
+ - QLoRA adds ~10% training time but halves VRAM needs
989
+ - Start with fewer epochs (1-2) to validate, then train full if results look good
990
+ - Use the get_recommended_gpu tool to get the optimal GPU for your specific setup
991
+
992
+ ## Multi-GPU
993
+ For models that don't fit in a single GPU's VRAM, the platform automatically shards across GPUs.
994
+ Specify gpu_count in create_training_job (default: 1).
995
+
996
+ ## Infrastructure and Price Caps
997
+ The platform selects and manages the underlying infrastructure automatically. You choose only the GPU type, the GPU count, and a maximum total hourly price cap. Preflight returns ranked alternatives and the exact price that will be honored. A price above the approved cap is rejected rather than silently accepted.`,
998
+ inference: `# Model Inference Guide
999
+
1000
+ Deployments serve either a verified fine-tuning checkpoint or a base model from the catalog through an OpenAI-compatible endpoint.
1001
+
1002
+ ## Settings the platform derives (not tool inputs)
1003
+ serving_mode, model_task and supports_images are SERVER-DERIVED and immutable,
1004
+ so preflight_inference/create_inference do not accept them:
1005
+ - serving_mode (full / adapter / merged) follows the verified checkpoint's
1006
+ lineage; a catalog base model is served whole.
1007
+ - model_task follows the resolved model — chat for an instruct or
1008
+ vision-language model, completion for a base model with no chat template,
1009
+ embedding/rerank for those architectures. Every model in the current catalog
1010
+ resolves to chat, so there is nothing to choose.
1011
+ - supports_images follows the model's own config.
1012
+ Read all three from the preflight/create response and from get_inference_status.
1013
+ If you copy canonical_request from preflight into create, these fields are
1014
+ ignored rather than honored — the server derives them again either way.
1015
+
1016
+ ## Context window
1017
+ - Default: min(model native max, 262144) tokens. A model whose native window is
1018
+ unknown falls back to 32768; a model with a smaller native window keeps it.
1019
+ - Hard ceiling: the model's OWN native max. A larger context_length is rejected
1020
+ with the model's maximum in the message; there is no platform-wide maximum you
1021
+ can rely on, so read model.native_max_context from preflight_inference.
1022
+ - Raising the window enlarges the KV cache and can raise min_gpus, so re-read
1023
+ get_inference_gpu_options after changing it.
1024
+
1025
+ ## Safe automation workflow
1026
+ 1. Call get_inference_gpu_options with model facts to obtain model-fit counts plus authoritative deployment stock and total hourly prices.
1027
+ 2. Choose a concrete GPU type/count. Never infer availability from fit alone and never substitute a different SKU without user approval.
1028
+ 3. Call preflight_inference. Review selected_gpu, alternatives, queue_required, billing.authorization_amount_cents, and billing.max_price_hour_cents.
1029
+ 4. If the chosen SKU is unavailable, ask the user to choose an available alternative or explicitly consent to allow_capacity_queue. The queue can wait up to seven days.
1030
+ 5. Copy canonical_request from preflight—including hf_model_revision/base_model_revision—into create_inference with the approved maximum total hourly price. Store the one-time inference_key securely.
1031
+ 6. Poll get_inference_status for durable queue/provisioning/loading/running state. Do not report the endpoint as live until status is running.
1032
+ 7. Stop or delete when no longer needed. Resume reauthorizes capacity under the saved price cap.
1033
+
1034
+ ## Billing and capacity semantics
1035
+ - Preflight is side-effect free.
1036
+ - Create authorizes two hours at the accepted maximum price without immediately reducing wallet balance.
1037
+ - One hour at the final confirmed price is captured only after capacity is accepted, and a price above the cap is rejected.
1038
+ - Deployment serving supports the secure inventory tier only. Unknown inventory is reported as unknown, never as zero or available.
1039
+ - Training and deployment use the shared capacity broker. It evaluates priority first, then rotates fairly across users at equal priority while preserving FIFO inside each user's own requests. This prevents a high-volume user from starving others; do not describe the queue as strict global FIFO.`,
1040
+ hyperparameters: `# Hyperparameter Guide
1041
+
1042
+ ## Key Hyperparameters
1043
+
1044
+ ### Learning Rate
1045
+ - Default: 2e-4 (0.0002)
1046
+ - Range: 1e-5 to 5e-4
1047
+ - Higher = faster learning but risk of instability
1048
+ - Lower = more stable but slower convergence
1049
+ - Tip: Start with 2e-4 for LoRA, 2e-5 for full fine-tune
1050
+
1051
+ ### Batch Size (per_device_train_batch_size)
1052
+ - Default: 4
1053
+ - Range: 1-32 (limited by GPU VRAM)
1054
+ - Larger = smoother gradients, more stable training
1055
+ - Smaller = faster per-step but noisier
1056
+ - Use gradient_accumulation_steps to simulate larger batches
1057
+
1058
+ ### Epochs (num_train_epochs)
1059
+ - Default: 3
1060
+ - Range: 1-10
1061
+ - More epochs = more passes through data
1062
+ - Watch for overfitting: if eval loss starts increasing, you've trained too long
1063
+ - For small datasets (<1000 rows): 3-5 epochs
1064
+ - For large datasets (>10000 rows): 1-3 epochs
1065
+
1066
+ ### LoRA Rank (lora_r)
1067
+ - Default: 16
1068
+ - Range: 4-256
1069
+ - Higher = more trainable parameters, better quality, but slower
1070
+ - 8-16 is sufficient for most tasks
1071
+ - 32-64 for complex tasks requiring more model capacity
1072
+ - 128+ for near-full-fine-tune quality
1073
+
1074
+ ### LoRA Alpha (lora_alpha)
1075
+ - Default: 32 (typically 2x lora_rank)
1076
+ - Controls the scaling of LoRA updates
1077
+ - Rule of thumb: set to 2x your lora_rank
1078
+
1079
+ ### Max Sequence Length
1080
+ - Default: 2048
1081
+ - Range: 512-8192
1082
+ - Must cover your longest training example
1083
+ - Longer = more VRAM usage
1084
+ - Set to slightly above your longest example
1085
+
1086
+ ### LR Scheduler
1087
+ - cosine (default) — gradually decreases, good for most tasks
1088
+ - linear — constant decrease, predictable
1089
+ - constant_with_warmup — stays flat after warmup, good for short training
1090
+
1091
+ ### Warmup Ratio
1092
+ - Default: 0.03 (3% of total steps)
1093
+ - Prevents early instability by slowly ramping up learning rate
1094
+
1095
+ ## Recommended Defaults by Use Case
1096
+ | Parameter | Quick Test | Standard | High Quality |
1097
+ |-----------|-----------|----------|--------------|
1098
+ | Adapter | LoRA | LoRA | Full |
1099
+ | Learning Rate | 3e-4 | 2e-4 | 2e-5 |
1100
+ | Epochs | 1 | 3 | 5 |
1101
+ | Batch Size | 4 | 4 | 8 |
1102
+ | LoRA Rank | 8 | 16 | 64 |
1103
+ | Scheduler | cosine | cosine | cosine |`,
1104
+ monitoring: `# Training Monitoring Guide
1105
+
1106
+ ## Key Metrics to Watch
1107
+
1108
+ ### Training Loss
1109
+ - Should decrease steadily during training
1110
+ - Sharp drops early are normal (learning basic patterns)
1111
+ - Gradual decrease later shows fine-grained learning
1112
+ - If loss plateaus: try higher learning rate or more LoRA rank
1113
+ - If loss spikes: learning rate is too high, reduce it
1114
+
1115
+ ### Eval Loss (if eval dataset provided)
1116
+ - Should track training loss but may be slightly higher
1117
+ - If eval loss increases while training loss decreases = OVERFITTING
1118
+ - Early sign of overfitting: gap between train and eval loss growing
1119
+ - Action: Stop training and use the last checkpoint before divergence
1120
+
1121
+ ### Learning Rate
1122
+ - Should follow your chosen schedule (cosine, linear, etc.)
1123
+ - Warmup period: LR starts low and ramps up
1124
+ - Main phase: LR follows the schedule downward
1125
+
1126
+ ## Monitoring Tools
1127
+ 1. get_training_status — Progress %, current step, elapsed time, ETA
1128
+ 2. get_training_metrics — Full loss curve data for analysis
1129
+ 3. get_training_logs — Raw training output for debugging
1130
+ 4. get_training_checkpoints — Saved model snapshots at intervals
1131
+
1132
+ ## When to Stop Early
1133
+ - Loss has plateaued for many steps (no improvement)
1134
+ - Eval loss is increasing (overfitting)
1135
+ - You've reached satisfactory quality and want to save GPU cost
1136
+ - The model is producing good outputs in eval samples
1137
+
1138
+ ## Checkpoints
1139
+ - Saved automatically at regular intervals during training
1140
+ - Each checkpoint is a complete model snapshot you can use
1141
+ - If training is interrupted, resume from the last checkpoint
1142
+ - Best practice: compare outputs from different checkpoints to pick the best one`,
1143
+ cost_optimization: `# Cost Optimization Guide
1144
+
1145
+ ## Per-Second Billing
1146
+ Run BiOS charges per-SECOND of GPU usage, not per hour. This means:
1147
+ - You only pay for actual compute time
1148
+ - Stopping a job immediately stops billing
1149
+ - No wasted time rounding up to the next hour
1150
+
1151
+ ## Cost Estimation
1152
+ Before launching a job, estimate cost:
1153
+ 1. Call get_recommended_gpu for your model — it includes a cost estimate
1154
+ 2. Rough formula: (dataset_rows / batch_size) × epochs × seconds_per_step × gpu_price_per_second
1155
+
1156
+ ## Money-Saving Strategies
1157
+
1158
+ ### 1. Start Small
1159
+ - Train for 1 epoch first to validate your setup
1160
+ - Check if the loss is decreasing and outputs look reasonable
1161
+ - Only then train for the full 3-5 epochs
1162
+
1163
+ ### 2. Use LoRA
1164
+ - LoRA is 5-10x cheaper than full fine-tune
1165
+ - Quality is comparable for most tasks
1166
+ - QLoRA is even cheaper (uses 4-bit quantization)
1167
+
1168
+ ### 3. Optimize Batch Size
1169
+ - Larger batch sizes = fewer total steps = less time
1170
+ - Use gradient accumulation to simulate large batches on limited VRAM
1171
+
1172
+ ### 4. Choose the Right GPU
1173
+ - Don't pick H100 for a 7B LoRA job — A100 is plenty and cheaper
1174
+ - Use get_recommended_gpu to find the cost-optimal choice
1175
+
1176
+ ### 5. Monitor and Stop Early
1177
+ - Watch the loss curve via get_training_metrics
1178
+ - If loss has plateaued, stop early — more epochs won't help
1179
+ - Check wallet balance with get_wallet_balance before long jobs
1180
+
1181
+ ### 6. Check Your Balance First
1182
+ - Call get_wallet_balance before creating a job
1183
+ - Ensure you have enough credits for the estimated training time
1184
+ - Set up auto top-up to avoid interruptions mid-training`,
1185
+ capacity_errors: `# Standard GPU Rejection Errors
1186
+
1187
+ Training and inference speak ONE GPU rejection contract: one body shape, and a
1188
+ code that tells you whether waiting can ever help. This MCP server hands you
1189
+ that body as compact JSON in the tool-error text.
1190
+
1191
+ ## Two classes, and the difference matters
1192
+ - CAPACITY_UNAVAILABLE (HTTP 409, reason insufficient_stock) is the ONLY
1193
+ transient one: the GPU is simply not free right now. Retrying later or
1194
+ joining the queue can succeed. Training also echoes the deprecated alias
1195
+ SELECTED_GPU_UNAVAILABLE as legacy_code for one release.
1196
+ - GPU_TYPE_TOO_SMALL (model_too_large), GPU_COUNT_BELOW_MINIMUM
1197
+ (below_model_minimum), GPU_COUNT_INVALID (invalid_gpu_count) and
1198
+ GPU_TYPE_UNSUPPORTED (gpu_unsupported) are HTTP 400 and PERMANENT: the
1199
+ request as submitted can never run on that GPU, whatever frees up. Change
1200
+ gpu_type or gpu_count. Do NOT wait and do NOT queue.
1201
+
1202
+ Read retryable_as_submitted (false = permanent) or queue_offered instead of
1203
+ parsing the message.
1204
+
1205
+ ## Body fields
1206
+ - reason: insufficient_stock | model_too_large | below_model_minimum | invalid_gpu_count | gpu_unsupported
1207
+ - selected: the gpu_type/gpu_count/tier that was refused, with its live availability_status/available_count
1208
+ - minimum_requirement: selected_gpu_min + selected_valid_counts for the chosen type, plus the per_type table (min_gpus + valid_counts for every type that fits the model). NEVER submit below min_gpus or outside valid_counts.
1209
+ - available_gpus: ALL currently bookable alternatives, cheapest first, each at its minimum count with valid_counts, available_count and price_hour_cents (available_alternatives is the deprecated alias)
1210
+ - queue_offered: true only for a stock miss. When it is true, you may resubmit with allow_capacity_queue=true to wait for stock, and nothing is charged while waiting. When it is false the queue cannot help this request at all.
1211
+ - queue_eligible: whether THIS request already asked to queue (never true when queue_offered is false)
1212
+ - gpu_priorities_entry: 1-based rank of the gpu_priorities entry that was refused, when the rejection belongs to one
1213
+ - checked_at: when the market snapshot behind the verdict was read
1214
+
1215
+ ## How to recover
1216
+ 1. Pick an entry from available_gpus and retry the SAME call with its gpu_type and a gpu_count >= its min_gpus (the listed gpu_count is that minimum). With zero alternatives, read minimum_requirement.per_type for the types that fit.
1217
+ 2. Only when queue_offered is true: resubmit with allow_capacity_queue=true (explicit user consent required) to auto-deploy when stock frees.
1218
+ 3. Never invent a GPU/count; never treat unknown availability as out-of-stock; never re-send an unchanged request that was refused as permanent.
1219
+
1220
+ ## Book-before-reveal (inference creates)
1221
+ A non-queued create_inference answers 202 with a booking handle while a
1222
+ GPU is secured on real capacity (30-40s typical). The deployment id and the
1223
+ ONE-TIME inference_key exist only after the booking is confirmed. A definitive
1224
+ miss returns this standard error with FRESH alternatives and NO deployment
1225
+ exists (nothing charged, nothing to clean up). Poll get_inference_booking for
1226
+ long bookings.
1227
+
1228
+ ## Transient outages are NOT capacity answers
1229
+ 503 ADMISSION_UNAVAILABLE (market/broker unreadable) means retry shortly.
1230
+ Never conclude out-of-stock from it.
1231
+
1232
+ ## After start
1233
+ Once a deployment has ever been booked, losing its GPU NEVER ends in a
1234
+ capacity failure: the platform replaces it across the ranked backup ladder and,
1235
+ when no stock exists anywhere, parks it as queued_capacity (waiting, zero
1236
+ charge) until stock returns. gpu_priorities backups may be ranked on ANY
1237
+ create (1-5 choices), and the queue itself stays opt-in.`,
1238
+ };
1239
+ // The overview and quick_start teach both creation workflows, so either
1240
+ // gate swaps them out; the single-surface topics follow their own gate.
1241
+ if (launchGates.training || launchGates.datasets) {
1242
+ guides.overview = gatedOverview;
1243
+ guides.quick_start = comingSoonNote;
1244
+ }
1245
+ if (launchGates.training) {
1246
+ guides.training_methods = comingSoonNote;
1247
+ guides.gpu_selection = comingSoonNote;
1248
+ guides.hyperparameters = comingSoonNote;
1249
+ guides.cost_optimization = comingSoonNote;
1250
+ }
1251
+ if (launchGates.datasets) {
1252
+ guides.datasets = comingSoonNote;
1253
+ }
1254
+ if (topic && guides[topic]) {
1255
+ return result(guides[topic]);
1256
+ }
1257
+ return result(guides.overview);
1258
+ });
1259
+ /* ══════════════════════════════════════════════════════════════════════════ */
1260
+ /* TOOL: introspect_api_key */
1261
+ /* ══════════════════════════════════════════════════════════════════════════ */
1262
+ server.tool("introspect_api_key", "Discover what this API key can do. Returns the key's permissions (scopes), the org and workspace it's bound to, allowed MCP tools and SDK methods, rate limits, and expiration. Call this first to understand your access level and available capabilities.", {}, async () => {
1263
+ const data = await client.api("/api/api-keys/introspect");
1264
+ return json(data);
1265
+ });
1266
+ /* ══════════════════════════════════════════════════════════════════════════ */
1267
+ /* TOOL: get_wallet_balance */
1268
+ /* ══════════════════════════════════════════════════════════════════════════ */
1269
+ server.tool("get_wallet_balance", "Check your current wallet balance, available credits, and pending charges. Call this before creating a training job to make sure you have enough credits. Returns available balance in dollars, pending charges from running jobs, and auto top-up status.", {}, async () => {
1270
+ const data = await client.api("/api/billing/wallet");
1271
+ return json(data);
1272
+ });
1273
+ /* ══════════════════════════════════════════════════════════════════════════ */
1274
+ /* TOOL: search_models */
1275
+ /* ══════════════════════════════════════════════════════════════════════════ */
1276
+ server.tool("search_models", launchGates.training
1277
+ ? "Search the Run BiOS model catalog — the platform's own hosted, verified models. These are the ONLY models that can be deployed on Run BiOS; a model that is not listed is not hosted yet (ask an admin to mirror it). Filter by name, provider, or parameter count. Returns model id, author, parameter counts, architecture, and downloads/likes for each result."
1278
+ : "Search the Run BiOS model catalog — the platform's own hosted, verified models. These are the ONLY models that can be fine-tuned or deployed on Run BiOS; a model that is not listed is not hosted yet (ask an admin to mirror it). Filter by name, provider, or parameter count. Returns model id, author, parameter counts, architecture, and downloads/likes for each result.", {
1279
+ query: z.string().optional().describe("Search by model name (e.g., 'llama', 'mistral', 'phi', 'qwen')"),
1280
+ provider: z.string().optional().describe("Filter by provider/author (e.g., 'meta-llama', 'mistralai', 'microsoft', 'Qwen')"),
1281
+ max_params: z.string().optional().describe("Maximum parameter count (e.g., '7B', '13B', '70B')"),
1282
+ }, async ({ query, provider, max_params }) => {
1283
+ // The registry matches query tokens against normalized repo ids
1284
+ // ("author/name"), so folding the provider into q turns it into a real
1285
+ // author filter instead of a silently ignored parameter.
1286
+ const q = [provider, query].filter(Boolean).join(" ").trim() || undefined;
1287
+ const data = await client.api("/api/public/model-search", {
1288
+ params: { q, max_params },
1289
+ });
1290
+ return json(data);
1291
+ });
1292
+ /* ══════════════════════════════════════════════════════════════════════════ */
1293
+ /* TOOL: list_models */
1294
+ /* ══════════════════════════════════════════════════════════════════════════ */
1295
+ server.tool("list_models", launchGates.training
1296
+ ? "List every model hosted on Run BiOS — the platform's own verified registry, mirrored in Run BiOS storage (never a live Hugging Face listing). These are the only models that can be deployed. Call this before create_inference to pick a valid model id; use search_models to filter the same catalog by text."
1297
+ : "List every model hosted on Run BiOS — the platform's own verified registry, mirrored in Run BiOS storage (never a live Hugging Face listing). These are the only models that can be fine-tuned or deployed. Call this before create_training_job or create_inference to pick a valid model id; use search_models to filter the same catalog by text.", {
1298
+ type: z.enum(["all", "llm", "vlm"]).optional().describe("Filter by model surface (default: all)."),
1299
+ sort: z.enum(["downloads", "likes", "trending", "name"]).optional().describe("Sort order (default: downloads)."),
1300
+ limit: z.number().int().min(1).max(60).optional().describe("Page size (default 24, max 60)."),
1301
+ offset: z.number().int().min(0).optional().describe("Pagination offset (default 0)."),
1302
+ }, async ({ type, sort, limit, offset }) => {
1303
+ const data = await client.api("/api/public/model-search", {
1304
+ params: {
1305
+ type: type && type !== "all" ? type : undefined,
1306
+ sort,
1307
+ limit: limit !== undefined ? String(limit) : undefined,
1308
+ offset: offset !== undefined ? String(offset) : undefined,
1309
+ },
1310
+ });
1311
+ return json(data);
1312
+ });
1313
+ /**
1314
+ * Registry gate for model selection: Run BiOS can only train and serve models it
1315
+ * hosts in its own registry. A definitive registry miss gets a clear,
1316
+ * actionable error pointing at the catalog; any failure to ANSWER (registry
1317
+ * unreachable) stays advisory — the server-side gate re-validates
1318
+ * authoritatively and unknown never fails closed.
1319
+ */
1320
+ async function assertModelHosted(modelId, tool) {
1321
+ const status = await client.modelRegistryStatus(modelId);
1322
+ if (status === "not_hosted") {
1323
+ // Same compact-JSON convention as the capacity errors: an MCP client is
1324
+ // an AI agent and can act on the instruction directly. The instruction
1325
+ // names every route to a valid id (see CATALOG_RECOVERY) rather than a
1326
+ // single tool, so the advice still works when that tool is degraded.
1327
+ throw new Error(JSON.stringify({
1328
+ code: "MODEL_NOT_HOSTED",
1329
+ message: `"${modelId}" is not in the Run BiOS model catalog, so it cannot be trained or served. Run BiOS only runs models it hosts and mirrors itself; ask an admin to mirror this one if you need it.`,
1330
+ recoverable: true,
1331
+ instruction: `${CATALOG_RECOVERY} Then retry ${tool} with that id.`,
1332
+ }));
1333
+ }
1334
+ }
1335
+ /**
1336
+ * Rewrite a model-resolution failure into the answer that is both TRUE and
1337
+ * actionable. The control plane surfaces its mirror's transport error verbatim
1338
+ * ("Could not resolve the model for sizing: Hugging Face request returned
1339
+ * 404"), which names an upstream vendor the caller cannot act on and never
1340
+ * states the real answer: that id is not a Run BiOS catalog model. Callers here
1341
+ * have already passed the registry gate, so a definitive catalog miss is
1342
+ * impossible at this point — the remaining causes are a bad revision or a
1343
+ * catalog entry whose mirrored facts cannot be read right now. Anything that is
1344
+ * not a resolution failure (capacity JSON, auth, validation) is returned
1345
+ * untouched.
1346
+ */
1347
+ function modelResolveFailure(error, modelId, tool) {
1348
+ const text = error instanceof Error ? error.message : String(error);
1349
+ const isResolveFailure = /hugging\s*face/i.test(text)
1350
+ || /could not resolve (the model|an immutable)/i.test(text)
1351
+ || /could not establish .*(immutable|commit)/i.test(text);
1352
+ if (!isResolveFailure)
1353
+ return error instanceof Error ? error : new Error(text);
1354
+ const subject = modelId ? `"${modelId}"` : "the requested model";
1355
+ if (/revision|commit/i.test(text)) {
1356
+ return new Error(JSON.stringify({
1357
+ code: "MODEL_REVISION_UNRESOLVED",
1358
+ message: `Run BiOS could not pin an exact, immutable commit for ${subject}, so it refused to size or deploy it. The revision you supplied is not one it can verify.`,
1359
+ recoverable: true,
1360
+ instruction: `Omit the revision to let the platform pin the catalog's current commit, or copy hf_model_revision/base_model_revision unchanged from preflight_inference's canonical_request. Then retry ${tool}.`,
1361
+ model: modelId,
1362
+ }));
1363
+ }
1364
+ return new Error(JSON.stringify({
1365
+ code: "MODEL_NOT_RESOLVED",
1366
+ message: `Run BiOS could not resolve ${subject} from its own model catalog, so it cannot be sized or deployed. A model that is not in the catalog can never resolve; a catalog model can also fail this way while its mirrored facts are being re-synced.`,
1367
+ recoverable: true,
1368
+ instruction: `${CATALOG_RECOVERY} Then retry ${tool}. If the id IS listed in the catalog, report a catalog sync problem to the user or an admin instead of retrying in a loop.`,
1369
+ model: modelId,
1370
+ }));
1371
+ }
1372
+ /* ══════════════════════════════════════════════════════════════════════════ */
1373
+ /* TOOL: get_model_config */
1374
+ /* ══════════════════════════════════════════════════════════════════════════ */
1375
+ server.tool("get_model_config", launchGates.training
1376
+ ? "Get configuration recommendations for a specific model hosted on Run BiOS (see list_models). Returns supported training methods, recommended GPU type and count, default hyperparameters, minimum VRAM required, and estimated cost per hour."
1377
+ : "Get training configuration recommendations for a specific model hosted on Run BiOS (see list_models). Returns supported training methods, recommended GPU type and count, default hyperparameters, minimum VRAM required, and estimated cost per hour. Use this after search_models to plan your training job.", {
1378
+ model: z.string().describe("Full model id from the Run BiOS catalog (e.g., 'meta-llama/Llama-3.1-8B-Instruct')"),
1379
+ model_revision: z
1380
+ .string()
1381
+ .max(256)
1382
+ .optional()
1383
+ .describe("Model branch, tag, or commit. The response returns the exact immutable commit used for sizing."),
1384
+ integration_id: z
1385
+ .string()
1386
+ .optional()
1387
+ .describe("Legacy no-op for catalog models — hosted models are never gated. Integrations are used for Hugging Face dataset imports."),
1388
+ }, async ({ model }) => {
1389
+ const data = await client.api("/api/public/model-config", { params: { id: model } });
1390
+ return json(data);
1391
+ });
1392
+ /* ══════════════════════════════════════════════════════════════════════════ */
1393
+ /* TOOL: list_supported_architectures */
1394
+ /* ══════════════════════════════════════════════════════════════════════════ */
1395
+ server.tool("list_supported_architectures", "List the architectures Run BiOS supports, so you know what can be trained or served before you try. scope='inference' returns the model architecture classes (e.g. LlamaForCausalLM) that can be DEPLOYED for serving; scope='training' returns the architecture keys the fine-tuning gate accepts. If count is 0 the registry is empty and nothing is explicitly restricted (every architecture Run BiOS supports is allowed). Support here is authoritative: the deployment and training gates read these same rows. The response carries NO build or version identity of any kind — read manifest_provenance, which says how many rows came from a published manifest without naming any build. Never report a platform version from this tool. No authentication required.", {
1396
+ scope: z.enum(["inference", "training"]).optional().describe("Which registry to read. Defaults to 'inference' (servable/deployable architectures)."),
1397
+ }, async ({ scope }) => {
1398
+ const data = await client.api("/api/public/serving-architectures", { params: { scope: scope || "inference" } });
1399
+ // The registry TABLE holds the build identity of the engine image whose
1400
+ // capability manifest published each row (image_tag/bios_version/
1401
+ // manifest_sha256, and the same tag again inside free-text `notes`). None
1402
+ // of it is a customer fact and it must never leave the platform, so the
1403
+ // endpoint now projects those columns away before answering.
1404
+ //
1405
+ // Both passes still run, in this order, and neither is redundant — they
1406
+ // defend against the projection regressing, which is the exact way this
1407
+ // leak reached production the first time:
1408
+ // 1. annotateArchitectureProvenance COUNTS how many rows came from a
1409
+ // manifest (from `source`, which survives the projection) and states
1410
+ // what the caller may conclude, so an agent cannot read provenance as
1411
+ // "the version the platform runs now". It goes FIRST because it needs
1412
+ // the rows before step 2 rewrites them.
1413
+ // 2. sanitizeArchitectureRegistry then DROPS every build-identity key at
1414
+ // any depth and content-redacts what is left, so anything the
1415
+ // projection ever fails to strip still never ships.
1416
+ return json(sanitizeArchitectureRegistry(annotateArchitectureProvenance(data)));
1417
+ });
1418
+ /* ══════════════════════════════════════════════════════════════════════════ */
1419
+ /* TOOL: get_gpu_pricing */
1420
+ /* ══════════════════════════════════════════════════════════════════════════ */
1421
+ server.tool("get_gpu_pricing", "Get current GPU pricing and availability. Returns all available GPU types with display name, VRAM in GB, per-second cost, availability status, and supported model sizes. No authentication required.", {}, async () => {
1422
+ const data = await client.api("/api/public/gpu-pricing");
1423
+ return json(data);
1424
+ });
1425
+ /* ══════════════════════════════════════════════════════════════════════════ */
1426
+ /* TOOL: get_recommended_gpu */
1427
+ /* ══════════════════════════════════════════════════════════════════════════ */
1428
+ server.tool("get_recommended_gpu", "Get authoritative, model-aware GPU options from the training control plane. Returns required counts, live availability, total hourly prices, the cheapest currently bookable recommendation, and compatible alternatives when no GPU is available. It does not invent a duration or total-cost estimate.", {
1429
+ model: z.string().describe("Full model name (e.g., 'meta-llama/Llama-3.1-8B-Instruct')"),
1430
+ adapter: z.enum(TRAINING_ADAPTERS).optional().describe("Adapter type (default: 'lora')."),
1431
+ method: z.enum(["sft", "pt", "rlhf"]).optional().describe("Canonical training method."),
1432
+ rlhf_type: z
1433
+ .enum(["dpo", "simpo", "orpo", "cpo", "kto", "rm"])
1434
+ .optional()
1435
+ .describe("RLHF algorithm when method is rlhf."),
1436
+ model_params_b: z.number().positive().optional().describe("Optional total-parameter hint in billions."),
1437
+ model_active_params_b: z
1438
+ .number()
1439
+ .positive()
1440
+ .optional()
1441
+ .describe("Optional active-parameter hint for MoE models in billions."),
1442
+ }, async (params) => {
1443
+ const data = await client.api("/api/training/gpu-options", {
1444
+ params: buildGPUOptionsParams(params),
1445
+ });
1446
+ return json(data);
1447
+ });
1448
+ /* ══════════════════════════════════════════════════════════════════════════ */
1449
+ /* TOOL: get_inference_gpu_options */
1450
+ /* ══════════════════════════════════════════════════════════════════════════ */
1451
+ server.tool("get_inference_gpu_options", "Get inference model-fit GPU choices joined to the authoritative deployment inventory and pricing snapshot. PREFER model-addressed sizing: pass model=<model_id> (optionally revision / hf_integration_id) and the SERVER resolves the facts (vision-aware) and returns computed min_gpus, valid_counts, and bookable_counts — the exact minimums the create gate enforces. Returns valid tensor-parallel counts, exact total hourly prices, availability as available/out_of_stock/unknown, and ranked alternatives. Never interpret unknown inventory as available, never offer a count below min_gpus or outside bookable_counts, and never substitute a SKU without user approval.", {
1452
+ model: z.string().optional().describe("Model id for SERVER-resolved sizing (recommended); replaces every client-fact field below."),
1453
+ revision: z.string().optional().describe("Exact model commit for model-addressed sizing."),
1454
+ inference_id: z.string().optional().describe("Size from an existing deployment's pinned facts."),
1455
+ hf_integration_id: z.string().optional().describe("Legacy field kept for compatibility — catalog models are pre-mirrored and never gated, so no integration is needed."),
1456
+ params_b: z.number().positive().optional().describe("DEPRECATED client-fact path: total model parameters in billions; required only when model/inference_id are absent."),
1457
+ active_params_b: z.number().positive().optional().describe("Active MoE parameters in billions, used only for throughput hints."),
1458
+ is_moe: z.boolean().optional(),
1459
+ is_multimodal: z.boolean().optional().describe("Vision tower present (client-fact path only): reserves extra VRAM and can raise minimums. Model-addressed sizing detects this automatically."),
1460
+ quant: quantSchema("Precision to size the weights and KV cache for."),
1461
+ context_length: contextLengthSchema("Context window to size the KV cache for."),
1462
+ kv_heads: z.number().int().positive().optional(),
1463
+ num_layers: z.number().int().positive().optional(),
1464
+ kv_layers: z.number().int().positive().optional(),
1465
+ head_dim: z.number().int().positive().optional(),
1466
+ attention: z.enum(["gqa", "mla"]).optional(),
1467
+ num_attention_heads: z.number().int().positive().optional(),
1468
+ kv_lora_rank: z.number().int().positive().optional(),
1469
+ qk_rope_head_dim: z.number().int().positive().optional(),
1470
+ gpu_tier: z.enum(DEPLOYMENT_GPU_TIERS).optional(),
1471
+ }, async (params) => {
1472
+ // Gate the model against the Run BiOS catalog FIRST. Without it the control
1473
+ // plane answers a sizing miss with its upstream mirror's 404 text, which
1474
+ // names a vendor and hides the real answer ("that id is not in the Run BiOS
1475
+ // catalog"). A registry that cannot answer stays advisory, exactly as on
1476
+ // the create path. This endpoint also accepts the "repo@revision" form, so
1477
+ // probe the repo id alone — the revision is not part of catalog identity.
1478
+ const catalogId = params.model?.split("@")[0]?.trim();
1479
+ if (catalogId)
1480
+ await assertModelHosted(catalogId, "get_inference_gpu_options");
1481
+ try {
1482
+ const data = await client.api("/api/inference/gpu-options", {
1483
+ params: buildDeploymentGPUOptionsParams(params),
1484
+ });
1485
+ return json(data);
1486
+ }
1487
+ catch (error) {
1488
+ throw modelResolveFailure(error, params.model, "get_inference_gpu_options");
1489
+ }
1490
+ });
1491
+ /* ══════════════════════════════════════════════════════════════════════════ */
1492
+ /* TOOL: preflight_inference */
1493
+ /* ══════════════════════════════════════════════════════════════════════════ */
1494
+ server.tool("preflight_inference", "Validate and canonicalize a deployment without creating a database row, authorizing funds, entering a queue, or allocating a GPU. Returns verified model facts, selected price/stock, compatible alternatives, queue requirements, a request hash, and two-hour wallet-authorization terms. The response is also where you READ the derived serving settings: serving_mode, model_task, supports_images and the resolved context_length are chosen by the platform from the model and checkpoint lineage and cannot be set as inputs. Review this result with the user before create_inference.", deploymentCreateToolSchema, async (params) => {
1495
+ if (params.source_type === "hf_model" && params.hf_model_id) {
1496
+ await assertModelHosted(params.hf_model_id, "preflight_inference");
1497
+ }
1498
+ try {
1499
+ const data = await client.api("/api/inference/preflight", {
1500
+ method: "POST",
1501
+ body: buildDeploymentBody(params),
1502
+ });
1503
+ return json(data);
1504
+ }
1505
+ catch (error) {
1506
+ throw modelResolveFailure(error, params.hf_model_id ?? params.base_model_id, "preflight_inference");
1507
+ }
1508
+ });
1509
+ /* ══════════════════════════════════════════════════════════════════════════ */
1510
+ /* TOOL: create_inference */
1511
+ /* ══════════════════════════════════════════════════════════════════════════ */
1512
+ /**
1513
+ * Render a GPU type at a count readably: the type name already says GPU, so
1514
+ * only the plural marker is added ("1 H100_80GB" vs "2 H100_80GB GPUs"). Keeps
1515
+ * the synthesized copy identical to the server's.
1516
+ */
1517
+ const gpusOfType = (gpuType, count) => (count === 1 ? gpuType : `${gpuType} GPUs`);
1518
+ /**
1519
+ * Self-preflight (book-first §2): validate the chosen gpu_type/gpu_count
1520
+ * against the SERVER's model-addressed sizing (computed min_gpus/valid_counts)
1521
+ * BEFORE any create POST, and synthesize the standard structured GPU rejection
1522
+ * when the selection can never be booked. Every rejection this raises is
1523
+ * permanent (GPU_TYPE_TOO_SMALL / GPU_COUNT_BELOW_MINIMUM / GPU_COUNT_INVALID),
1524
+ * never CAPACITY_UNAVAILABLE: the selection is unbookable whatever the stock is.
1525
+ * Advisory only: a failure to ANSWER (endpoint unreachable) never blocks, the
1526
+ * create gate re-validates authoritatively and unknown never fails closed.
1527
+ */
1528
+ async function assertInferenceGpuSelectionBookable(body) {
1529
+ const model = (body.hf_model_id || body.base_model_id);
1530
+ const gpuType = body.gpu_type;
1531
+ const gpuCount = body.gpu_count;
1532
+ if (!model || !gpuType || !Number.isInteger(gpuCount))
1533
+ return;
1534
+ let options;
1535
+ try {
1536
+ options = await client.api("/api/inference/gpu-options", {
1537
+ params: {
1538
+ model: String(model),
1539
+ revision: (body.hf_model_revision || body.base_model_revision),
1540
+ quant: body.quant,
1541
+ context_length: body.context_length !== undefined ? String(body.context_length) : undefined,
1542
+ hf_integration_id: body.hf_integration_id,
1543
+ },
1544
+ });
1545
+ }
1546
+ catch {
1547
+ return; // advisory only — the create gate is the enforcement floor
1548
+ }
1549
+ const chosen = options?.options?.find((option) => option?.gpu_type === gpuType);
1550
+ if (!chosen)
1551
+ return;
1552
+ const minGpus = chosen.min_gpus || 0;
1553
+ const validCounts = (chosen.valid_counts || []).filter((count) => Number.isInteger(count));
1554
+ let reason;
1555
+ let message = "";
1556
+ if (chosen.selectable === false) {
1557
+ reason = "model_too_large";
1558
+ message = `This model does not fit on ${gpuType}, at any GPU count.`;
1559
+ }
1560
+ else if (minGpus > 0 && gpuCount < minGpus) {
1561
+ reason = "below_model_minimum";
1562
+ message = `This model needs at least ${minGpus} ${gpusOfType(gpuType, minGpus)} to run, and this request asked for ${gpuCount}.`;
1563
+ }
1564
+ else if (validCounts.length > 0 && !validCounts.includes(gpuCount)) {
1565
+ reason = "invalid_gpu_count";
1566
+ message = `This model cannot be split across ${gpuCount} ${gpusOfType(gpuType, gpuCount)}. Use one of these GPU counts instead: ${validCounts.join(", ")}.`;
1567
+ }
1568
+ if (!reason)
1569
+ return;
1570
+ const selectable = (options.options || []).filter((option) => option && option.selectable !== false);
1571
+ const alternatives = selectable
1572
+ .filter((option) => option.market?.availability_status === "available")
1573
+ .map((option) => ({
1574
+ gpu_type: option.gpu_type,
1575
+ gpu_count: option.min_gpus,
1576
+ min_gpus: option.min_gpus,
1577
+ valid_counts: option.valid_counts,
1578
+ tier: "secure",
1579
+ available_count: option.market?.available_count ?? undefined,
1580
+ price_hour_cents: (option.market?.price_per_gpu_hour_cents || 0) * (option.min_gpus || 1),
1581
+ }));
1582
+ // Same structured shape, codes and instruction text the transport serializes
1583
+ // for real API GPU rejections, so agent clients handle exactly one contract.
1584
+ // Every reason this pre-check can raise is a permanent fact about the request
1585
+ // (the model does not fit, the count is below the model's minimum, the count
1586
+ // cannot be used), so the code is never CAPACITY_UNAVAILABLE and the queue is
1587
+ // never offered: waiting for stock could not book any of them.
1588
+ const permanent = isPermanentGpuCode(gpuRejectionCodeFor(reason));
1589
+ throw new Error(JSON.stringify({
1590
+ code: gpuRejectionCodeFor(reason),
1591
+ status: gpuRejectionStatusFor(reason),
1592
+ reason,
1593
+ message,
1594
+ recoverable: true,
1595
+ retryable_as_submitted: !permanent,
1596
+ instruction: gpuRejectionInstruction({
1597
+ permanent,
1598
+ hasAlternatives: alternatives.length > 0,
1599
+ call: "create_inference",
1600
+ }),
1601
+ available_gpus: alternatives,
1602
+ minimum_requirement: {
1603
+ selected_gpu_min: minGpus,
1604
+ selected_valid_counts: validCounts,
1605
+ per_type: selectable
1606
+ .filter((option) => (option.min_gpus || 0) >= 1)
1607
+ .map((option) => ({
1608
+ gpu_type: option.gpu_type,
1609
+ min_gpus: option.min_gpus,
1610
+ valid_counts: option.valid_counts || [],
1611
+ })),
1612
+ },
1613
+ selected: { gpu_type: gpuType, gpu_count: gpuCount, tier: body.gpu_tier || "secure", availability_status: "unknown" },
1614
+ // A permanent rejection must not advertise the queue, and must not report
1615
+ // itself eligible for it either: a caller branching on queue_eligible would
1616
+ // park a request that can never book.
1617
+ queue_offered: !permanent,
1618
+ queue_eligible: body.allow_capacity_queue === true && !permanent,
1619
+ checked_at: options.availability_checked_at,
1620
+ }));
1621
+ }
1622
+ /**
1623
+ * Book-before-reveal (book-first §1): poll a 202 booking handle to its
1624
+ * terminal outcome. Pending keeps polling; a transient 503/market outage
1625
+ * keeps polling (never a capacity verdict); the definitive miss throws the
1626
+ * structured CAPACITY_UNAVAILABLE the transport already serialized; on
1627
+ * timeout the raw booking body is returned with an instruction to keep
1628
+ * polling get_inference_booking.
1629
+ */
1630
+ async function pollInferenceBookingToOutcome(handle, timeoutMs) {
1631
+ const deadline = Date.now() + timeoutMs;
1632
+ for (;;) {
1633
+ if (Date.now() > deadline) {
1634
+ return {
1635
+ booking: { handle, status: "booking" },
1636
+ instruction: `The GPU booking is still concluding. Nothing is charged and no deployment exists until a GPU is confirmed. Call get_inference_booking with handle "${handle}" to continue polling.`,
1637
+ };
1638
+ }
1639
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
1640
+ let outcome;
1641
+ try {
1642
+ outcome = await client.api(`/api/inference/bookings/${encodeURIComponent(handle)}`);
1643
+ }
1644
+ catch (error) {
1645
+ const text = error instanceof Error ? error.message : String(error);
1646
+ // ADMISSION_UNAVAILABLE / 503: transient market outage — keep polling,
1647
+ // never fail-closed. Every other error (including the structured
1648
+ // CAPACITY_UNAVAILABLE miss) is terminal.
1649
+ if (text.includes("ADMISSION_UNAVAILABLE") || text.includes("error 503"))
1650
+ continue;
1651
+ throw error;
1652
+ }
1653
+ if (outcome?.booking?.status === "booking")
1654
+ continue;
1655
+ return outcome;
1656
+ }
1657
+ }
1658
+ server.tool("create_inference", "Create an OpenAI-compatible serving deployment using the exact canonical_request approved through preflight_inference, including hf_model_revision/base_model_revision. Serving mode, OpenAI task and image support are derived by the platform (not inputs) and come back on the response. BOOK-BEFORE-REVEAL: a non-queued create answers 202 with a booking handle while the GPU is secured on real capacity (30-40s typical); this tool polls it and returns the full payload (one-time inference_key) only after the GPU is confirmed — a definitive miss returns the structured CAPACITY_UNAVAILABLE with fresh available_gpus + minimum_requirement and NO deployment exists. Queueing occurs only with explicit allow_capacity_queue=true, max_price_hour_cents prevents a later price increase. Reuse idempotency_key after a timeout to recover the same booking/deployment.", deploymentCreateMutationToolSchema, async ({ idempotency_key, ...params }) => {
1659
+ if (params.source_type === "hf_model" && params.hf_model_id) {
1660
+ await assertModelHosted(params.hf_model_id, "create_inference");
1661
+ }
1662
+ const body = buildDeploymentBody(params);
1663
+ await assertInferenceGpuSelectionBookable(body);
1664
+ try {
1665
+ const data = await client.api("/api/inference", {
1666
+ method: "POST",
1667
+ body,
1668
+ headers: { "Idempotency-Key": idempotency_key },
1669
+ });
1670
+ if (data?.booking?.handle) {
1671
+ return json(await pollInferenceBookingToOutcome(String(data.booking.handle), 120_000));
1672
+ }
1673
+ return json(data);
1674
+ }
1675
+ catch (error) {
1676
+ throw modelResolveFailure(error, params.hf_model_id ?? params.base_model_id, "create_inference");
1677
+ }
1678
+ });
1679
+ /* ══════════════════════════════════════════════════════════════════════════ */
1680
+ /* TOOL: get_inference_booking */
1681
+ /* ══════════════════════════════════════════════════════════════════════════ */
1682
+ server.tool("get_inference_booking", "Poll a book-before-reveal booking handle from create_inference. Returns {booking:{status:'booking'}} while the GPU is being confirmed, the full create payload (with the ONE-TIME inference_key, exactly once) once the GPU is secured, the structured CAPACITY_UNAVAILABLE error on a definitive miss (no deployment exists, nothing charged), or a retryable ADMISSION_UNAVAILABLE 503 during a transient market outage (never treat it as out-of-stock).", { handle: z.string().describe("Booking handle from create_inference's 202 response.") }, async ({ handle }) => {
1683
+ const data = await client.api(`/api/inference/bookings/${encodeURIComponent(handle)}`);
1684
+ return json(data);
1685
+ });
1686
+ /* ══════════════════════════════════════════════════════════════════════════ */
1687
+ /* TOOL: list_inferences */
1688
+ /* ══════════════════════════════════════════════════════════════════════════ */
1689
+ server.tool("list_inferences", "List one bounded, newest-first page of serving deployments in the API key's workspace. Reuse next_cursor with the same status/search filters to continue; start without a cursor after filters change. The returned lifecycle values are durable control-plane state, not a promise that an endpoint is live unless status is running.", {
1690
+ limit: z.number().int().min(1).max(200).optional().describe("Page size (default 50, maximum 200)."),
1691
+ cursor: z.string().max(1024).optional().describe("Opaque next_cursor from the preceding page."),
1692
+ status: z.enum([
1693
+ "all", "running", "provisioning", "stopped", "failed", "queued_capacity", "degraded",
1694
+ "paused_insufficient_funds", "crash_loop", "downloading_weights", "loading_model",
1695
+ ]).optional(),
1696
+ search: z.string().max(120).optional().describe("Case-insensitive deployment-name prefix."),
1697
+ }, async ({ limit, cursor, status, search }) => {
1698
+ const params = new URLSearchParams();
1699
+ if (limit !== undefined)
1700
+ params.set("limit", String(limit));
1701
+ if (cursor)
1702
+ params.set("cursor", cursor);
1703
+ if (status && status !== "all")
1704
+ params.set("status", status);
1705
+ if (search?.trim())
1706
+ params.set("search", search.trim());
1707
+ const query = params.toString();
1708
+ const data = await client.api(`/api/inference${query ? `?${query}` : ""}`);
1709
+ return json(data);
1710
+ });
1711
+ /* ══════════════════════════════════════════════════════════════════════════ */
1712
+ /* TOOL: get_inference_status */
1713
+ /* ══════════════════════════════════════════════════════════════════════════ */
1714
+ server.tool("get_inference_status", "Read durable deployment status, desired state, loading progress, endpoint, errors, selected GPU/price cap, queue entry/expiry, notification summary, and wallet-authorization state. Also returns status_summary: the lifecycle phase, the GPU (count x SKU), whether the endpoint may be used, and the queue/GPU state, derived from this same response so a field that is null before the pod reports is not mistaken for 'no GPU' or 'no phase'. A queue deadline keeps status=failed for filter compatibility and sets status_reason/error_code=queue_expired. Treat queued_capacity as waiting and report the endpoint live only when status_summary.endpoint_usable is true. The derived serving settings (serving_mode, model_task, supports_images, context_length) are reported here too — they are platform-chosen, never caller-chosen.", { deployment_id: z.string().describe("Deployment ID.") }, async ({ deployment_id }) => {
1715
+ validateId(deployment_id, "deployment_id");
1716
+ const data = await client.api(`/api/inference/${deployment_id}`);
1717
+ // Same raw-text class as the training job detail (top-level last_error and
1718
+ // the nested queue.last_error). The control plane curates these today; this
1719
+ // is the boundary backstop, and it touches no other field.
1720
+ //
1721
+ // Redaction runs OUTSIDE the summariser deliberately: summarizeInferenceStatus
1722
+ // derives status_summary from these same raw fields, so redacting first would
1723
+ // let it summarise text the backstop had already cleaned — and redacting last
1724
+ // covers anything it copied forward.
1725
+ return json(redactFreeTextFields(summarizeInferenceStatus(data), RAW_FAILURE_TEXT_FIELDS));
1726
+ });
1727
+ /* ══════════════════════════════════════════════════════════════════════════ */
1728
+ /* TOOL: get_inference_metrics */
1729
+ /* ══════════════════════════════════════════════════════════════════════════ */
1730
+ server.tool("get_inference_metrics", "Read serving metrics for a deployment: request counts, prompt/generation token totals, cache hit rate, time to first token, inter-token latency, throughput, GPU utilization and memory, KV cache usage, and a recent time series. Counters are lifetime totals; the time series reflects the selected window. Responses are served from a short cache; zeros are real measurements, not missing data.", {
1731
+ deployment_id: z.string().describe("Deployment ID."),
1732
+ window: z
1733
+ .enum(["1h", "24h", "7d", "30d"])
1734
+ .optional()
1735
+ .describe("Time-series window (default 24h). Sets the series range and bucket size: 1h/1min, 24h/5min, 7d/30min, 30d/1h."),
1736
+ }, async ({ deployment_id, window }) => {
1737
+ validateId(deployment_id, "deployment_id");
1738
+ const params = new URLSearchParams();
1739
+ if (window)
1740
+ params.set("window", window);
1741
+ const query = params.toString();
1742
+ const data = await client.api(`/api/inference/${deployment_id}/metrics${query ? `?${query}` : ""}`);
1743
+ return json(data);
1744
+ });
1745
+ /* ══════════════════════════════════════════════════════════════════════════ */
1746
+ /* TOOL: get_inference_notifications */
1747
+ /* ══════════════════════════════════════════════════════════════════════════ */
1748
+ server.tool("get_inference_notifications", "Read the durable lifecycle-email history for a deployment. States are pending, sending, delivered, or dead_letter. Dead letters retain the bounded retry count and last error; deployment status remains authoritative even when email delivery fails.", {
1749
+ deployment_id: z.string().describe("Deployment ID."),
1750
+ limit: z.number().int().min(1).max(100).optional().describe("Newest rows to return (default 50, maximum 100)."),
1751
+ }, async ({ deployment_id, limit }) => {
1752
+ validateId(deployment_id, "deployment_id");
1753
+ const params = new URLSearchParams();
1754
+ if (limit !== undefined)
1755
+ params.set("limit", String(limit));
1756
+ const query = params.toString();
1757
+ const data = await client.api(`/api/inference/${deployment_id}/notifications${query ? `?${query}` : ""}`);
1758
+ return json(data);
1759
+ });
1760
+ /* ══════════════════════════════════════════════════════════════════════════ */
1761
+ /* TOOL: update_inference_policy */
1762
+ /* ══════════════════════════════════════════════════════════════════════════ */
1763
+ server.tool("update_inference_policy", "Update explicit capacity-queue consent or the maximum accepted total hourly GPU price. A cap below the current verified price is rejected. This call does not silently choose another GPU.", {
1764
+ deployment_id: z.string().describe("Deployment ID."),
1765
+ allow_capacity_queue: z.boolean().optional(),
1766
+ max_price_hour_cents: priceCapSchema(false),
1767
+ }, async ({ deployment_id, allow_capacity_queue, max_price_hour_cents }) => {
1768
+ validateId(deployment_id, "deployment_id");
1769
+ const data = await client.api(`/api/inference/${deployment_id}`, {
1770
+ method: "PATCH",
1771
+ body: buildDeploymentPolicyBody({ allow_capacity_queue, max_price_hour_cents }),
1772
+ });
1773
+ return json(data);
1774
+ });
1775
+ /* ══════════════════════════════════════════════════════════════════════════ */
1776
+ /* TOOL: stop_inference */
1777
+ /* ══════════════════════════════════════════════════════════════════════════ */
1778
+ server.tool("stop_inference", "Stop a deployment and release GPU capacity plus its wallet authorization. The acknowledgement means the durable stop workflow was accepted; poll get_inference_status until stopped or failed.", { deployment_id: z.string().describe("Deployment ID.") }, async ({ deployment_id }) => {
1779
+ validateId(deployment_id, "deployment_id");
1780
+ const data = await client.api(`/api/inference/${deployment_id}/stop`, { method: "POST" });
1781
+ return json(data);
1782
+ });
1783
+ /* ══════════════════════════════════════════════════════════════════════════ */
1784
+ /* TOOL: resume_inference */
1785
+ /* ══════════════════════════════════════════════════════════════════════════ */
1786
+ server.tool("resume_inference", "Atomically resume a stopped deployment. Exactly one concurrent caller can claim the lifecycle generation. The control plane revalidates the saved GPU price cap and wallet authorization before provisioning; capacity may queue only when the saved queue consent allows it.", { deployment_id: z.string().describe("Deployment ID.") }, async ({ deployment_id }) => {
1787
+ validateId(deployment_id, "deployment_id");
1788
+ const data = await client.api(`/api/inference/${deployment_id}/resume`, { method: "POST" });
1789
+ return json(data);
1790
+ });
1791
+ /* ══════════════════════════════════════════════════════════════════════════ */
1792
+ /* TOOL: restart_inference */
1793
+ /* ══════════════════════════════════════════════════════════════════════════ */
1794
+ server.tool("restart_inference", "Claim one fenced restart generation. A same-config running deployment restarts in place and never silently releases its GPU for a replacement when the platform cannot confirm that restart. Pending configuration changes and failed/crash-loop recovery explicitly provision a replacement. Poll status because the acknowledgement is not endpoint readiness.", { deployment_id: z.string().describe("Deployment ID.") }, async ({ deployment_id }) => {
1795
+ validateId(deployment_id, "deployment_id");
1796
+ const data = await client.api(`/api/inference/${deployment_id}/restart`, { method: "POST" });
1797
+ return json(data);
1798
+ });
1799
+ /* ══════════════════════════════════════════════════════════════════════════ */
1800
+ /* TOOL: delete_inference */
1801
+ /* ══════════════════════════════════════════════════════════════════════════ */
1802
+ server.tool("delete_inference", "Permanently delete a serving deployment. The control plane first verifies infrastructure teardown and records retryable orphan cleanup if needed, then releases/refunds the generation-scoped wallet authorization. This cannot be undone.", { deployment_id: z.string().describe("Deployment ID.") }, async ({ deployment_id }) => {
1803
+ validateId(deployment_id, "deployment_id");
1804
+ const data = await client.api(`/api/inference/${deployment_id}`, { method: "DELETE" });
1805
+ return json(data);
1806
+ });
1807
+ /* ══════════════════════════════════════════════════════════════════════════ */
1808
+ /* TOOL: list_datasets */
1809
+ /* ══════════════════════════════════════════════════════════════════════════ */
1810
+ server.tool("list_datasets", "List all datasets in your workspace. Returns dataset ID, name, format (JSONL/Parquet/CSV), row count, file size, column names, and creation date for each dataset.", {}, async () => {
1811
+ const data = await client.api("/api/datasets");
1812
+ return json(data);
1813
+ });
1814
+ /* ══════════════════════════════════════════════════════════════════════════ */
1815
+ /* TOOL: upload_dataset */
1816
+ /* ══════════════════════════════════════════════════════════════════════════ */
1817
+ // PRE-LAUNCH GATE (launchGates.datasets — src/config.ts): while datasets are
1818
+ // coming soon this tool is NOT REGISTERED — hidden from tools/list so an agent
1819
+ // never learns the capability exists. The service enforces the same gate
1820
+ // server-side (403 DATASETS_COMING_SOON) as the authority. At launch the flag
1821
+ // opens and the registration returns verbatim.
1822
+ if (!launchGates.datasets) {
1823
+ server.tool("upload_dataset", "Upload a dataset file for fine-tuning. Supports JSONL, Parquet, and CSV formats up to 500MB. The file is automatically validated: format detection, row counting, column identification, and schema validation. Returns the dataset ID needed for create_training_job.", {
1824
+ file_path: z.string().describe("Absolute path to the dataset file on disk"),
1825
+ name: z.string().optional().describe("Display name for the dataset (defaults to filename)"),
1826
+ }, async ({ file_path, name }) => {
1827
+ const absPath = resolve(file_path);
1828
+ const allowedExtensions = [".jsonl", ".parquet", ".csv", ".json", ".tsv", ".txt"];
1829
+ const ext = absPath.toLowerCase().slice(absPath.lastIndexOf("."));
1830
+ if (!allowedExtensions.includes(ext)) {
1831
+ throw new Error(`Unsupported file type: ${ext}. Allowed: ${allowedExtensions.join(", ")}`);
1832
+ }
1833
+ const normalizedPath = absPath.replace(/\\/g, "/");
1834
+ const sensitivePatterns = ["/etc/", "/.ssh/", "/.env", "/.git/", "/passwd", "/shadow", "/.aws/"];
1835
+ for (const pat of sensitivePatterns) {
1836
+ if (normalizedPath.includes(pat)) {
1837
+ throw new Error(`Access denied: cannot read files from restricted paths`);
1838
+ }
1839
+ }
1840
+ let fileStat;
1841
+ try {
1842
+ fileStat = await stat(absPath);
1843
+ }
1844
+ catch {
1845
+ throw new Error(`File not found: ${absPath}`);
1846
+ }
1847
+ if (fileStat.size > 500 * 1024 * 1024) {
1848
+ throw new Error(`File too large (${(fileStat.size / 1024 / 1024).toFixed(1)}MB). Maximum is 500MB.`);
1849
+ }
1850
+ const fileBuffer = await readFile(absPath);
1851
+ const fileName = name ?? basename(absPath);
1852
+ const blob = new Blob([fileBuffer]);
1853
+ const formData = new FormData();
1854
+ formData.append("file", blob, basename(absPath));
1855
+ formData.append("name", fileName);
1856
+ formData.append("size", fileStat.size.toString());
1857
+ const data = await client.api("/api/datasets/upload", {
1858
+ method: "POST",
1859
+ formData,
1860
+ });
1861
+ return json(data);
1862
+ });
1863
+ }
1864
+ /* ══════════════════════════════════════════════════════════════════════════ */
1865
+ /* TOOL: preview_dataset */
1866
+ /* ══════════════════════════════════════════════════════════════════════════ */
1867
+ server.tool("preview_dataset", "Preview the first rows of a dataset to inspect its structure, column names, and content before training. Useful for verifying the dataset format is correct.", {
1868
+ dataset_id: z.string().describe("Dataset ID (e.g., 'ds_abc123')"),
1869
+ rows: z.number().optional().describe("Number of rows to preview (default: 10, max: 50)"),
1870
+ }, async ({ dataset_id, rows }) => {
1871
+ validateId(dataset_id, "dataset_id");
1872
+ const data = await client.api(`/api/datasets/${dataset_id}/preview`, {
1873
+ params: { page: "1", page_size: (rows ?? 10).toString() },
1874
+ });
1875
+ return json(data);
1876
+ });
1877
+ /* ══════════════════════════════════════════════════════════════════════════ */
1878
+ /* TOOL: delete_dataset */
1879
+ /* ══════════════════════════════════════════════════════════════════════════ */
1880
+ server.tool("delete_dataset", "Permanently delete a dataset from your workspace. This cannot be undone. Active training jobs using this dataset are not affected.", {
1881
+ dataset_id: z.string().describe("Dataset ID to delete"),
1882
+ }, async ({ dataset_id }) => {
1883
+ validateId(dataset_id, "dataset_id");
1884
+ await client.api(`/api/datasets/${dataset_id}`, { method: "DELETE" });
1885
+ return result(`Dataset ${dataset_id} deleted successfully.`);
1886
+ });
1887
+ /* ══════════════════════════════════════════════════════════════════════════ */
1888
+ /* TOOL: list_integrations */
1889
+ /* ══════════════════════════════════════════════════════════════════════════ */
1890
+ server.tool("list_integrations", "List the integrations connected to your workspace, such as Hugging Face accounts. Returns each integration's ID, label, provider type, and status. Use an integration ID wherever a tool accepts integration_id: importing HuggingFace datasets. This is how you pick WHICH connected account an import should use when several are connected. This read is workspace-scoped: without workspace context it fails with WORKSPACE_CONTEXT_REQUIRED, which is a configuration fix (BIOS_WORKSPACE_ID or a workspace-bound API key), not something to retry.", {}, async () => {
1891
+ const data = await client.api("/api/datasets/integrations");
1892
+ return json(data);
1893
+ });
1894
+ /* ══════════════════════════════════════════════════════════════════════════ */
1895
+ /* TOOL: import_huggingface_dataset */
1896
+ /* ══════════════════════════════════════════════════════════════════════════ */
1897
+ // PRE-LAUNCH GATE (launchGates.datasets — src/config.ts): not registered while
1898
+ // gated, exactly like upload_dataset above; the service's 403
1899
+ // DATASETS_COMING_SOON stays the authority.
1900
+ if (!launchGates.datasets) {
1901
+ server.tool("import_huggingface_dataset", "Import a dataset from HuggingFace Hub into your workspace. Works with public datasets and private datasets if your HuggingFace account is connected via Integrations. The dataset is downloaded, validated, and stored in your workspace. Returns the dataset ID for use with create_training_job.", {
1902
+ repo_id: z
1903
+ .string()
1904
+ .describe("HuggingFace dataset repo (e.g., 'databricks/dolly-15k', 'tatsu-lab/alpaca')"),
1905
+ integration_id: z
1906
+ .string()
1907
+ .describe("HuggingFace integration ID from your connected integrations"),
1908
+ split: z.string().optional().describe("Dataset split (default: 'train')"),
1909
+ max_samples: z.number().optional().describe("Max rows to import (imports all if omitted)"),
1910
+ name: z.string().optional().describe("Display name for the imported dataset"),
1911
+ }, async ({ repo_id, integration_id, split, max_samples, name }) => {
1912
+ const data = await client.api(`/api/datasets/integrations/${encodeURIComponent(integration_id)}/import`, {
1913
+ method: "POST",
1914
+ body: {
1915
+ dataset_id: repo_id,
1916
+ split: split ?? "train",
1917
+ max_samples,
1918
+ name: name ?? repo_id.split("/").pop(),
1919
+ },
1920
+ });
1921
+ return json(data);
1922
+ });
1923
+ }
1924
+ /* ══════════════════════════════════════════════════════════════════════════ */
1925
+ /* TOOL: get_training_capabilities */
1926
+ /* ══════════════════════════════════════════════════════════════════════════ */
1927
+ server.tool("get_training_capabilities", "Return the authoritative training contract Run BiOS enforces today: enabled and disabled methods, algorithms and adapters; exact hyperparameter names, aliases, types, defaults, enums and ranges; dependencies and incompatibilities. contract_version/schema_version identify the contract you are reading. Call this before constructing an automated training payload instead of relying on a hard-coded client list.", {}, async () => {
1928
+ const data = await client.api("/api/training/capabilities");
1929
+ // The contract used to carry image_compatibility — the training-engine image
1930
+ // VERSION range it was written against. training-service no longer emits it;
1931
+ // this strip stays as a backstop because a customer cannot select a build and
1932
+ // must never learn one, and contract_version/schema_version already version
1933
+ // the contract for clients.
1934
+ return json(stripBuildIdentity(data));
1935
+ });
1936
+ /* ══════════════════════════════════════════════════════════════════════════ */
1937
+ /* TOOL: preflight_training_job */
1938
+ /* ══════════════════════════════════════════════════════════════════════════ */
1939
+ server.tool("preflight_training_job", "Validate and canonicalize a proposed training job without creating a job, charging the wallet, entering the queue, or allocating a GPU. Returns dataset compatibility, model facts, authoritative GPU choices/prices, queue eligibility, alternatives, warnings, and a request hash.", {
1940
+ model: z.string().describe("Base model id from the Run BiOS catalog (e.g., 'meta-llama/Llama-3.1-8B-Instruct'). Must be hosted on Run BiOS — pick from list_models/search_models. Training always starts from a catalog base model; training FROM a fine-tuned model (adapter stacking) is not supported."),
1941
+ model_revision: z.string().max(256).optional().describe("Requested model branch, tag, or commit. Copy the exact 40-hex value returned in canonical_request into create."),
1942
+ dataset_id: z.string().optional(),
1943
+ dataset_ids: z.array(z.string()).min(1).optional(),
1944
+ method: z.enum(TRAINING_METHODS),
1945
+ adapter: z.enum(TRAINING_ADAPTERS).optional(),
1946
+ gpu_type: z.string().optional(),
1947
+ gpu_count: z.number().int().min(1).max(8).optional(),
1948
+ gpu_priorities: z
1949
+ .array(z.object({
1950
+ gpu_type: z.string(), gpu_count: z.number().int().min(1).max(8),
1951
+ provider: z.string().min(1).max(30).optional(), region: z.string().min(1).max(64).optional(),
1952
+ tier: z.literal("secure").optional(),
1953
+ }))
1954
+ .min(1)
1955
+ .max(5)
1956
+ .optional()
1957
+ .describe("Ranked GPU placement choices. Copy these unchanged from the preflight response's accepted choices; the platform fills in and manages infrastructure placement automatically. Queueing requires 3-5 distinct entries; entry one must match gpu_type/gpu_count."),
1958
+ queue_if_unavailable: z.boolean().optional().describe("Explicit consent to wait in the capacity queue when none of the ranked GPU choices are free; requires 3 to 5 gpu_priorities."),
1959
+ queue_deadline: z.string().min(1).optional().describe("Optional RFC 3339 deadline from one minute through seven days in the future; requires queue_if_unavailable=true."),
1960
+ max_price_hour_cents: priceCapSchema(true),
1961
+ storage_gb: z.number().int().min(1).max(10000).optional(),
1962
+ num_checkpoints: z.number().int().min(1).max(100).optional(),
1963
+ integration_id: z.string().optional(),
1964
+ network_volume_id: z.string().optional(),
1965
+ cache_dataset: z.boolean().optional(),
1966
+ dataset_mixing: z.enum(["shuffle", "sequential", "interleave"]).optional(),
1967
+ config: z.record(z.string(), z.unknown()).optional(),
1968
+ }, async (params) => {
1969
+ await assertModelHosted(params.model, "preflight_training_job");
1970
+ const body = buildTrainingCreateBody(params);
1971
+ const data = await client.api("/api/training/preflight", { method: "POST", body });
1972
+ return json(data);
1973
+ });
1974
+ /* ══════════════════════════════════════════════════════════════════════════ */
1975
+ /* TOOL: create_training_job */
1976
+ /* ══════════════════════════════════════════════════════════════════════════ */
1977
+ // PRE-LAUNCH GATE (launchGates.training — src/config.ts): while fine-tuning is
1978
+ // coming soon this tool is NOT REGISTERED — hidden from tools/list so an agent
1979
+ // never learns the capability exists. The service enforces the same gate
1980
+ // server-side (403 TRAINING_COMING_SOON before any billing or booking) as the
1981
+ // authority. At launch the flag opens and the registration returns verbatim.
1982
+ if (!launchGates.training) {
1983
+ server.tool("create_training_job", "Create a fine-tuning job using the same contract as the web UI, following BOOK-BEFORE-REVEAL: the call blocks while the ranked GPU ladder is booked (~40s). A job id and the started email exist only once a real pod is secured, so status is 'booked' (booked==secured; training then provisions/downloads/runs on its own) or 'securing' (still booking at the deadline — poll get_training_status until booked/running). With explicit queue consent it may return 'queued'. A definitive booking-time miss returns the structured CAPACITY_UNAVAILABLE (409) with fresh available_gpus and NO job exists (nothing charged); never auto-substitute a GPU. Call preflight_training_job first, preserve the accepted ranked GPU choices, queue deadline, and maximum total hourly price, then reuse one idempotency key after timeouts.", {
1984
+ model: z.string().describe("Base model id from the Run BiOS catalog (e.g., 'meta-llama/Llama-3.1-8B-Instruct'). Must be hosted on Run BiOS — pick from list_models/search_models. Training always starts from a catalog base model; training FROM a fine-tuned model (adapter stacking) is not supported."),
1985
+ model_revision: z.string().max(256).optional().describe("Exact model_revision from preflight canonical_request; a branch or tag is accepted but resolved again before any paid mutation."),
1986
+ idempotency_key: z
1987
+ .string()
1988
+ .min(8)
1989
+ .max(128)
1990
+ .regex(/^[A-Za-z0-9._:-]+$/)
1991
+ .optional()
1992
+ .describe("Stable retry key. Reuse it after a timeout; omit to generate one for this tool call."),
1993
+ dataset_id: z.string().optional().describe("Compatibility field for one dataset ID."),
1994
+ dataset_ids: z.array(z.string()).min(1).optional().describe("One or more dataset IDs to compose for training."),
1995
+ method: z.enum(TRAINING_METHODS).describe("SFT/PT or an offline preference/reward method."),
1996
+ adapter: z.enum(TRAINING_ADAPTERS).optional().describe("Any adapter supported by the Run BiOS contract."),
1997
+ gpu_type: z.string().optional().describe("GPU type (e.g., 'A100_80GB'). Use get_recommended_gpu to find the best option. Auto-selected if omitted."),
1998
+ gpu_count: z.number().int().min(1).max(8).optional().describe("Number of GPUs for the primary choice."),
1999
+ gpu_priorities: z
2000
+ .array(z.object({
2001
+ gpu_type: z.string(), gpu_count: z.number().int().min(1).max(8),
2002
+ provider: z.string().min(1).max(30).optional(), region: z.string().min(1).max(64).optional(),
2003
+ tier: z.literal("secure").optional(),
2004
+ }))
2005
+ .min(1)
2006
+ .max(5)
2007
+ .optional()
2008
+ .describe("Ranked GPU placement choices. Copy these unchanged from the preflight response's accepted choices; the platform fills in and manages infrastructure placement automatically. Queueing requires 3-5 distinct entries; entry one must match gpu_type/gpu_count."),
2009
+ queue_if_unavailable: z.boolean().optional().describe("Explicit consent to wait in the capacity queue when none of the ranked GPU choices are free; requires 3 to 5 gpu_priorities."),
2010
+ queue_deadline: z.string().min(1).optional().describe("Optional RFC 3339 deadline from one minute through seven days in the future; requires queue_if_unavailable=true."),
2011
+ max_price_hour_cents: priceCapSchema(true),
2012
+ storage_gb: z.number().int().min(1).max(10000).optional(),
2013
+ num_checkpoints: z.number().int().min(1).max(100).optional(),
2014
+ epochs: z.number().positive().max(1000).optional().describe("Number of training epochs."),
2015
+ learning_rate: z.number().positive().max(1).optional().describe("Learning rate."),
2016
+ batch_size: z.number().int().min(1).max(65536).optional().describe("Per-device train batch size."),
2017
+ gradient_accumulation_steps: z.number().int().min(1).max(65536).optional(),
2018
+ max_seq_length: trainingSeqLengthSchema(),
2019
+ lora_rank: z.number().int().min(1).max(4096).optional(),
2020
+ lora_alpha: z.number().positive().max(1_000_000).optional(),
2021
+ warmup_ratio: z.number().min(0).max(1).optional(),
2022
+ weight_decay: z.number().min(0).max(10).optional(),
2023
+ scheduler: z
2024
+ .enum(["linear", "cosine", "cosine_with_restarts", "polynomial", "constant", "constant_with_warmup"])
2025
+ .optional()
2026
+ .describe("Learning-rate scheduler."),
2027
+ job_name: z.string().max(255).optional(),
2028
+ eval_split_ratio: z.number().min(0).max(0.99).optional(),
2029
+ integration_id: z.string().optional(),
2030
+ network_volume_id: z.string().optional(),
2031
+ cache_dataset: z.boolean().optional(),
2032
+ dataset_mixing: z.enum(["shuffle", "sequential", "interleave"]).optional(),
2033
+ config: z.record(z.string(), z.unknown()).optional().describe("Additional Run BiOS training configuration fields."),
2034
+ }, async (params) => {
2035
+ await assertModelHosted(params.model, "create_training_job");
2036
+ const body = buildTrainingCreateBody(params);
2037
+ const data = await client.api("/api/training/jobs", {
2038
+ method: "POST",
2039
+ body,
2040
+ headers: { "Idempotency-Key": params.idempotency_key ?? randomUUID() },
2041
+ });
2042
+ return json(data);
2043
+ });
2044
+ }
2045
+ /* ══════════════════════════════════════════════════════════════════════════ */
2046
+ /* TOOL: list_training_jobs */
2047
+ /* ══════════════════════════════════════════════════════════════════════════ */
2048
+ server.tool("list_training_jobs", "List all training jobs with their status, progress percentage, base model, training method, GPU type, cost so far, and timestamps. Filter by status to find running, completed, or stopped jobs.", {
2049
+ status: z
2050
+ .enum(["running", "completed", "stopped", "queued", "securing", "booked", "interrupted", "failed"])
2051
+ .optional()
2052
+ .describe("Filter by job status"),
2053
+ }, async ({ status }) => {
2054
+ const data = await client.api("/api/training/jobs", { params: { status } });
2055
+ return json(data);
2056
+ });
2057
+ /* ══════════════════════════════════════════════════════════════════════════ */
2058
+ /* TOOL: get_training_status */
2059
+ /* ══════════════════════════════════════════════════════════════════════════ */
2060
+ server.tool("get_training_status", "Get detailed status for a specific training job: progress percentage, current step/total steps, current loss, learning rate, elapsed time, estimated time remaining, GPU utilization, and job configuration. Status 'securing' means a create is still booking a pod (poll until 'booked'); 'booked' means the GPU is secured and the job is starting. 'interrupted' is a self-heal rest state after a started job loses its pod — billing is already stopped and it is distinct from 'failed'; call resume_training_job to continue from the last checkpoint.", {
2061
+ job_id: z.string().describe("Training job ID (e.g., 'job_abc123')"),
2062
+ }, async ({ job_id }) => {
2063
+ validateId(job_id, "job_id");
2064
+ const data = await client.api(`/api/training/jobs/${job_id}`);
2065
+ // A stopped job's stop_last_error is the raw transport error from the last
2066
+ // call to the machine, so it carries the request URL and with it the
2067
+ // supplier's hostname. Redact the raw-text fields only; model ids, config
2068
+ // and every other value pass through untouched.
2069
+ return json(redactFreeTextFields(data, RAW_FAILURE_TEXT_FIELDS));
2070
+ });
2071
+ /* ══════════════════════════════════════════════════════════════════════════ */
2072
+ /* TOOL: get_training_metrics */
2073
+ /* ══════════════════════════════════════════════════════════════════════════ */
2074
+ server.tool("get_training_metrics", "Get training metrics history for a job: training loss over time, eval loss, learning rate schedule, and evaluation results. Use this to check if training is progressing well (loss should decrease) or if the model is overfitting (eval loss increasing while train loss decreases).", {
2075
+ job_id: z.string().describe("Training job ID"),
2076
+ }, async ({ job_id }) => {
2077
+ validateId(job_id, "job_id");
2078
+ const data = await client.api(`/api/training/jobs/${job_id}/metrics`);
2079
+ return json(data);
2080
+ });
2081
+ /* ══════════════════════════════════════════════════════════════════════════ */
2082
+ /* TOOL: get_training_logs */
2083
+ /* ══════════════════════════════════════════════════════════════════════════ */
2084
+ server.tool("get_training_logs", "Get structured recent training log entries (level, message, timestamp). Results are newest first and bounded by tail.", {
2085
+ job_id: z.string().describe("Training job ID"),
2086
+ tail: z
2087
+ .number()
2088
+ .int()
2089
+ .min(1)
2090
+ .max(1000)
2091
+ .optional()
2092
+ .describe("Number of most recent log entries to return (default: 200, max: 1000)"),
2093
+ }, async ({ job_id, tail }) => {
2094
+ validateId(job_id, "job_id");
2095
+ const data = await client.api(`/api/training/jobs/${job_id}/logs`, {
2096
+ params: { tail: tail?.toString() },
2097
+ });
2098
+ // Log lines are written by the training engine, so a boot/pull line can
2099
+ // quote the image reference it started from. Redact by content: the
2100
+ // customer keeps every line, minus any build identity inside it.
2101
+ return json(redactBuildIdentityDeep(trimTrainingLogs(data, tail)));
2102
+ });
2103
+ /* ══════════════════════════════════════════════════════════════════════════ */
2104
+ /* TOOL: stop_training_job */
2105
+ /* ══════════════════════════════════════════════════════════════════════════ */
2106
+ server.tool("stop_training_job", "Request a durable stop for an active training job. The acknowledgement means the stop workflow was accepted; checkpoint preservation and resumability are reported by later job/checkpoint state, not promised by this call.", {
2107
+ job_id: z.string().describe("Training job ID to stop"),
2108
+ keep_data: z.boolean().optional().describe("Attempt to preserve checkpoint/output data (default: true)."),
2109
+ }, async ({ job_id, keep_data }) => {
2110
+ validateId(job_id, "job_id");
2111
+ const data = await client.api(`/api/training/jobs/${job_id}/stop`, {
2112
+ method: "POST",
2113
+ body: { keep_data: keep_data ?? true },
2114
+ });
2115
+ return json(data);
2116
+ });
2117
+ /* ══════════════════════════════════════════════════════════════════════════ */
2118
+ /* TOOL: resume_training_job */
2119
+ /* ══════════════════════════════════════════════════════════════════════════ */
2120
+ server.tool("resume_training_job", "Resume a stopped or 'interrupted' training job from its latest checkpoint when the server has verified it as resumable. 'interrupted' is the self-heal rest state a started job enters after losing its pod (billing already stopped, distinct from 'failed'). Resume re-books on secured capacity before reporting resumed (book-before-reveal still applies). The response identifies the job; exact optimizer/epoch continuation depends on the checkpoint manifest and is not assumed by this tool.", {
2121
+ job_id: z.string().describe("Training job ID to resume"),
2122
+ idempotency_key: z
2123
+ .string()
2124
+ .min(8)
2125
+ .max(128)
2126
+ .regex(/^[A-Za-z0-9._:-]+$/)
2127
+ .optional()
2128
+ .describe("Stable retry key. Reuse it after a timeout; omit to generate one for this tool call."),
2129
+ }, async ({ job_id, idempotency_key }) => {
2130
+ validateId(job_id, "job_id");
2131
+ const data = await client.api(`/api/training/jobs/${job_id}/resume`, {
2132
+ method: "POST",
2133
+ headers: { "Idempotency-Key": idempotency_key ?? randomUUID() },
2134
+ });
2135
+ return json(data);
2136
+ });
2137
+ /* ══════════════════════════════════════════════════════════════════════════ */
2138
+ /* TOOL: get_training_checkpoints */
2139
+ /* ══════════════════════════════════════════════════════════════════════════ */
2140
+ server.tool("get_training_checkpoints", "List recorded checkpoints for a training job. Returns checkpoint identity, step/epoch, size and final/best flags. Use get_checkpoint_download_manifest for file URLs; this list does not fabricate loss, eval metrics, or download URLs.", {
2141
+ job_id: z.string().describe("Training job ID"),
2142
+ }, async ({ job_id }) => {
2143
+ validateId(job_id, "job_id");
2144
+ const data = await client.api(`/api/training/jobs/${job_id}/checkpoints`);
2145
+ return json(data);
2146
+ });
2147
+ /* ══════════════════════════════════════════════════════════════════════════ */
2148
+ /* TOOL: get_checkpoint_download_manifest */
2149
+ /* ══════════════════════════════════════════════════════════════════════════ */
2150
+ server.tool("get_checkpoint_download_manifest", "Get the server-generated per-file download manifest for a checkpoint. URLs are short-lived. Large checkpoints should be downloaded as individual resumable files and verified against the returned sizes/checksums when present.", {
2151
+ job_id: z.string().describe("Training job ID"),
2152
+ checkpoint_id: z.string().describe("Checkpoint ID from get_training_checkpoints"),
2153
+ }, async ({ job_id, checkpoint_id }) => {
2154
+ validateId(job_id, "job_id");
2155
+ validateId(checkpoint_id, "checkpoint_id");
2156
+ const data = await client.api(`/api/training/jobs/${job_id}/checkpoints/${checkpoint_id}/download`);
2157
+ return json(data);
2158
+ });
2159
+ /* ══════════════════════════════════════════════════════════════════════════ */
2160
+ /* TOOL: delete_training_checkpoint */
2161
+ /* ══════════════════════════════════════════════════════════════════════════ */
2162
+ server.tool("delete_training_checkpoint", "Delete a checkpoint after the API verifies ownership. This is destructive and can remove the ability to deploy or resume from that checkpoint.", {
2163
+ job_id: z.string().describe("Training job ID"),
2164
+ checkpoint_id: z.string().describe("Checkpoint ID"),
2165
+ }, async ({ job_id, checkpoint_id }) => {
2166
+ validateId(job_id, "job_id");
2167
+ validateId(checkpoint_id, "checkpoint_id");
2168
+ const data = await client.api(`/api/training/jobs/${job_id}/checkpoints/${checkpoint_id}`, {
2169
+ method: "DELETE",
2170
+ });
2171
+ return json(data);
2172
+ });
2173
+ /* ══════════════════════════════════════════════════════════════════════════ */
2174
+ /* TOOL: get_training_evals */
2175
+ /* ══════════════════════════════════════════════════════════════════════════ */
2176
+ server.tool("get_training_evals", "Get evaluation results for a training job. Returns benchmark scores, eval metrics, and model quality assessments computed during or after training.", {
2177
+ job_id: z.string().describe("Training job ID"),
2178
+ }, async ({ job_id }) => {
2179
+ validateId(job_id, "job_id");
2180
+ const data = await client.api(`/api/training/jobs/${job_id}/evals`);
2181
+ return json(data);
2182
+ });
2183
+ /* ══════════════════════════════════════════════════════════════════════════ */
2184
+ /* TOOL: chat_with_inference */
2185
+ /* ══════════════════════════════════════════════════════════════════════════ */
2186
+ // The schema is capability-aware per deployment (honest advertisement only —
2187
+ // the runtime is gated server-side). When the inference key's deployment is
2188
+ // known to be text-only (no tool parser), the tools/tool_choice/
2189
+ // parallel_tool_calls params are omitted so a caller never offers tools a
2190
+ // text-only model can't call. When it is known to accept no image input, the
2191
+ // messages description says so. Unknown capabilities (undefined) advertise the
2192
+ // full schema (fail-open) — the /v1 endpoint still enforces.
2193
+ const inferenceSupportsTools = deploymentCaps?.supportsTools !== false;
2194
+ const inferenceMessagesDesc = deploymentCaps?.supportsImages === false
2195
+ ? "OpenAI chat messages (text-only — this deployment does not accept image input), including assistant tool_calls and matching role=tool responses."
2196
+ : "OpenAI chat messages, including assistant tool_calls and matching role=tool responses.";
2197
+ const chatWithInferenceDescription = "Call a model through the unified OpenAI-compatible /v1 endpoint. Pass a serverless catalog model id (author/name) to call it by name using a workspace platform key (RUNBIOS_API_KEY with serverless scope), or set RUNBIOS_INFERENCE_KEY to target a dedicated deployment (with or without a model override). Supports complete tool definitions, assistant tool calls, and tool response messages. Set stream=true to consume SSE and receive one aggregated completion with reasoning surfaced as produced. Calls are never retried automatically.";
2198
+ // Shared, always-present params. The tools params are added only for a
2199
+ // deployment that can call tools — kept as two concrete literals (not a Record)
2200
+ // so each registration's `input` stays precisely typed for buildInferenceBody.
2201
+ const chatBaseSchema = {
2202
+ messages: z
2203
+ .array(z.record(z.string(), z.unknown()))
2204
+ .min(1)
2205
+ .describe(inferenceMessagesDesc),
2206
+ model: z
2207
+ .string()
2208
+ .optional()
2209
+ .describe("Model to call on the unified /v1 endpoint. For a serverless catalog model pass its id "
2210
+ + "(author/name) and the gateway routes it; for a dedicated deployment pass its name/id, or "
2211
+ + "omit when a dedicated inference key uniquely selects the deployment."),
2212
+ // Completion length had the same defect the context bounds had: `int()` alone
2213
+ // advertised MAX_SAFE_INTEGER, so the schema told an agent that asking for
2214
+ // nine quadrillion tokens is a legal request. The real ceiling is the SERVED
2215
+ // window minus the prompt, which no static schema can express, so the bound
2216
+ // is the same sanity ceiling serving uses and the description names the
2217
+ // authority.
2218
+ max_tokens: z
2219
+ .number()
2220
+ .int({ error: "max_tokens must be a whole number of tokens." })
2221
+ .min(1, { error: "max_tokens must be at least 1 — omit it to let the endpoint choose." })
2222
+ .max(CONTEXT_TOOL_MAX, {
2223
+ error: (issue) => `max_tokens ${String(issue.input)} is above the ${CONTEXT_TOOL_MAX}-token sanity bound of this tool. `
2224
+ + MAX_TOKENS_POLICY_TEXT,
2225
+ })
2226
+ .optional()
2227
+ .describe(MAX_TOKENS_POLICY_TEXT),
2228
+ temperature: z.number().min(0).max(2).optional(),
2229
+ top_p: z.number().min(0).max(1).optional(),
2230
+ response_format: z.record(z.string(), z.unknown()).optional(),
2231
+ reasoning_effort: z
2232
+ .enum(["none", "minimal", "low", "medium", "high", "max"])
2233
+ .optional()
2234
+ .describe("Standardized reasoning effort. Use 'none' to disable reasoning where the model allows it."),
2235
+ stream: z
2236
+ .boolean()
2237
+ .optional()
2238
+ .describe("Request server-sent streaming. The tool consumes the SSE and returns one aggregated "
2239
+ + "completion (content + reasoning_content surfaced as produced, tool calls reassembled). "
2240
+ + "Defaults to false."),
2241
+ idempotency_key: z
2242
+ .string()
2243
+ .min(1)
2244
+ .max(255)
2245
+ .optional()
2246
+ .describe("Stable caller-generated key to reuse only when explicitly retrying the same POST."),
2247
+ request_id: z.string().min(1).max(255).optional(),
2248
+ };
2249
+ if (inferenceSupportsTools) {
2250
+ server.tool("chat_with_inference", chatWithInferenceDescription, {
2251
+ ...chatBaseSchema,
2252
+ tools: z
2253
+ .array(z.record(z.string(), z.unknown()))
2254
+ .optional()
2255
+ .describe("OpenAI function-tool definitions with JSON Schema object parameters."),
2256
+ tool_choice: z
2257
+ .union([
2258
+ z.enum(["none", "auto", "required"]),
2259
+ z.object({
2260
+ type: z.literal("function"),
2261
+ function: z.object({ name: z.string().min(1).max(64) }),
2262
+ }),
2263
+ ])
2264
+ .optional(),
2265
+ parallel_tool_calls: z.boolean().optional(),
2266
+ }, async (input, extra) => {
2267
+ const body = buildInferenceBody(input);
2268
+ const data = await client.inferenceApi(body, input.idempotency_key, input.request_id, extra.signal);
2269
+ return json(data);
2270
+ });
2271
+ }
2272
+ else {
2273
+ // Text-only deployment: no tools/tool_choice params advertised.
2274
+ server.tool("chat_with_inference", chatWithInferenceDescription, chatBaseSchema, async (input, extra) => {
2275
+ const body = buildInferenceBody(input);
2276
+ const data = await client.inferenceApi(body, input.idempotency_key, input.request_id, extra.signal);
2277
+ return json(data);
2278
+ });
2279
+ }
2280
+ return mcp;
2281
+ }
2282
+ //# sourceMappingURL=server.js.map