nosana-mcp 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +128 -0
- package/dist/cli/commands/account.d.ts +7 -0
- package/dist/cli/commands/account.js +108 -0
- package/dist/cli/commands/account.js.map +1 -0
- package/dist/cli/commands/deploy.d.ts +3 -0
- package/dist/cli/commands/deploy.js +311 -0
- package/dist/cli/commands/deploy.js.map +1 -0
- package/dist/cli/commands/gpus.d.ts +3 -0
- package/dist/cli/commands/gpus.js +38 -0
- package/dist/cli/commands/gpus.js.map +1 -0
- package/dist/cli/commands/job.d.ts +3 -0
- package/dist/cli/commands/job.js +31 -0
- package/dist/cli/commands/job.js.map +1 -0
- package/dist/cli/commands/run.d.ts +33 -0
- package/dist/cli/commands/run.js +365 -0
- package/dist/cli/commands/run.js.map +1 -0
- package/dist/cli/commands/templates.d.ts +3 -0
- package/dist/cli/commands/templates.js +115 -0
- package/dist/cli/commands/templates.js.map +1 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +45 -0
- package/dist/cli/index.js.map +1 -0
- package/dist/core/client.d.ts +23 -0
- package/dist/core/client.js +38 -0
- package/dist/core/client.js.map +1 -0
- package/dist/core/config.d.ts +18 -0
- package/dist/core/config.js +52 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/credits.d.ts +9 -0
- package/dist/core/credits.js +9 -0
- package/dist/core/credits.js.map +1 -0
- package/dist/core/deploy.d.ts +146 -0
- package/dist/core/deploy.js +400 -0
- package/dist/core/deploy.js.map +1 -0
- package/dist/core/format.d.ts +25 -0
- package/dist/core/format.js +144 -0
- package/dist/core/format.js.map +1 -0
- package/dist/core/markets.d.ts +74 -0
- package/dist/core/markets.js +146 -0
- package/dist/core/markets.js.map +1 -0
- package/dist/core/templates.d.ts +61 -0
- package/dist/core/templates.js +190 -0
- package/dist/core/templates.js.map +1 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +73 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/tools.d.ts +52 -0
- package/dist/mcp/tools.js +994 -0
- package/dist/mcp/tools.js.map +1 -0
- package/docs/CLI.md +144 -0
- package/docs/COST.md +32 -0
- package/docs/MINIMAX-H3.md +79 -0
- package/examples/hello-world.json +18 -0
- package/examples/minimax-h3-i2v-32gb.json +56 -0
- package/package.json +66 -0
|
@@ -0,0 +1,994 @@
|
|
|
1
|
+
import { validateJobDefinition } from '@nosana/kit';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { CliError, createClient } from '../core/client.js';
|
|
4
|
+
import { maskKey, resolveApiKey } from '../core/config.js';
|
|
5
|
+
import { availableCredits, summarizeBalance } from '../core/credits.js';
|
|
6
|
+
import { assessPlan, createDeployment, dashboardUrl, defaultDeploymentName, explorerJobUrl, formatJobResult, MIN_TIMEOUT_MINUTES, normalizeJobState, parseStrategy, PHASE_HINTS, snapshotDeployment, validatePlan, waitForDeployment, } from '../core/deploy.js';
|
|
7
|
+
import { formatError, pageSize, stripAnsi, withRetry } from '../core/format.js';
|
|
8
|
+
import { autoPickGpu, bucketGpus, loadGpuCatalog, resolveGpu } from '../core/markets.js';
|
|
9
|
+
import { exposedPorts, exposesPorts, hardwareHints, isBlackwell, kindOfDefinition, listTemplates, llmFlavor, NeedsVariantError, prepareJobDefinition, primaryOpId, resolveTemplate, topLevelTemplates, typicalBootMinutes, vramFromDefinition, } from '../core/templates.js';
|
|
10
|
+
export const WAIT_DEFAULT_SECONDS = 30;
|
|
11
|
+
/** MCP clients time out requests after 60 s by default; stay well under it. */
|
|
12
|
+
export const WAIT_MAX_SECONDS = 45;
|
|
13
|
+
export const NO_KEY_MESSAGE = 'No Nosana API key. Add NOSANA_API_KEY to the MCP server environment (create one at https://deploy.nosana.com under Account > API Keys), or run `nosana-deploy login` once on this machine.';
|
|
14
|
+
export class ToolContext {
|
|
15
|
+
network;
|
|
16
|
+
client;
|
|
17
|
+
templatesCache;
|
|
18
|
+
catalogCache = new Map();
|
|
19
|
+
constructor(network) {
|
|
20
|
+
this.network = network;
|
|
21
|
+
}
|
|
22
|
+
hasKey() {
|
|
23
|
+
return Boolean(resolveApiKey());
|
|
24
|
+
}
|
|
25
|
+
get() {
|
|
26
|
+
if (!this.client) {
|
|
27
|
+
const key = resolveApiKey();
|
|
28
|
+
if (!key)
|
|
29
|
+
throw new CliError(NO_KEY_MESSAGE, 2);
|
|
30
|
+
this.client = createClient({ network: this.network }, key.key, 'none');
|
|
31
|
+
}
|
|
32
|
+
return this.client;
|
|
33
|
+
}
|
|
34
|
+
templates() {
|
|
35
|
+
const ttl = 10 * 60_000;
|
|
36
|
+
if (!this.templatesCache || Date.now() - this.templatesCache.at > ttl) {
|
|
37
|
+
const value = listTemplates(this.get());
|
|
38
|
+
this.templatesCache = { at: Date.now(), value };
|
|
39
|
+
value.catch(() => (this.templatesCache = undefined));
|
|
40
|
+
}
|
|
41
|
+
return this.templatesCache.value;
|
|
42
|
+
}
|
|
43
|
+
catalog(includeAll = false, onchain = false) {
|
|
44
|
+
const key = `${includeAll}:${onchain}`;
|
|
45
|
+
const ttl = 45_000;
|
|
46
|
+
const hit = this.catalogCache.get(key);
|
|
47
|
+
if (!hit || Date.now() - hit.at > ttl) {
|
|
48
|
+
const value = loadGpuCatalog(this.get(), { includeAll, onchain });
|
|
49
|
+
this.catalogCache.set(key, { at: Date.now(), value });
|
|
50
|
+
value.catch(() => this.catalogCache.delete(key));
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
return hit.value;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function tool(name, meta, shape, run) {
|
|
57
|
+
return { name, shape, ...meta, run: (ctx, args) => run(ctx, z.object(shape).parse(args ?? {})) };
|
|
58
|
+
}
|
|
59
|
+
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
60
|
+
const SPENDS = { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true };
|
|
61
|
+
const DESTRUCTIVE = { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true };
|
|
62
|
+
const round = (n) => Math.round(n * 1000) / 1000;
|
|
63
|
+
const BILLING_NOTES = {
|
|
64
|
+
idle_burns_credits: true,
|
|
65
|
+
no_pause: 'There is no pause. stop_deployment ends billing; start_deployment starts a new job and pays the boot time again.',
|
|
66
|
+
minimum_timeout_minutes: MIN_TIMEOUT_MINUTES,
|
|
67
|
+
};
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// Views
|
|
70
|
+
function gpuView(m, risks = []) {
|
|
71
|
+
const view = {
|
|
72
|
+
gpu: m.slug,
|
|
73
|
+
name: m.name,
|
|
74
|
+
vram_gb: m.vramGb,
|
|
75
|
+
usd_per_hour: round(m.pricePerHour),
|
|
76
|
+
idle_hosts: m.availableNodes,
|
|
77
|
+
};
|
|
78
|
+
if (m.queuedJobs !== null)
|
|
79
|
+
view.jobs_waiting = m.queuedJobs;
|
|
80
|
+
if (risks.length)
|
|
81
|
+
view.risks = risks;
|
|
82
|
+
return view;
|
|
83
|
+
}
|
|
84
|
+
const bucketList = (items) => items.map((b) => gpuView(b.market, b.risks));
|
|
85
|
+
function bucketsView(buckets, verbose = false) {
|
|
86
|
+
const view = {
|
|
87
|
+
ready_now: bucketList(buckets.ready_now),
|
|
88
|
+
fits_but_queued: bucketList(buckets.fits_but_queued),
|
|
89
|
+
idle_with_risk: bucketList(buckets.idle_with_risk),
|
|
90
|
+
unsupported: buckets.unsupported.map((b) => ({ gpu: b.market.slug, name: b.market.name, idle_hosts: b.market.availableNodes, reason: b.risks[0] })),
|
|
91
|
+
too_small_count: buckets.too_small.length,
|
|
92
|
+
};
|
|
93
|
+
if (verbose)
|
|
94
|
+
view.too_small = bucketList(buckets.too_small);
|
|
95
|
+
return view;
|
|
96
|
+
}
|
|
97
|
+
function templateView(t, all) {
|
|
98
|
+
const variants = t.variants.map((v) => {
|
|
99
|
+
const vt = all.find((x) => x.id === `${t.id}-${v.id}`);
|
|
100
|
+
return { id: v.id, name: v.name, description: v.description, vram_gb: vt?.vramRequirementGb ?? null };
|
|
101
|
+
});
|
|
102
|
+
const vrams = variants.map((v) => v.vram_gb).filter((n) => n !== null);
|
|
103
|
+
const definition = t.jobDefinition ?? all.find((x) => x.id === `${t.id}-${t.variants[0]?.id}`)?.jobDefinition ?? null;
|
|
104
|
+
return {
|
|
105
|
+
id: t.id,
|
|
106
|
+
name: t.name,
|
|
107
|
+
kind: definition ? kindOfDefinition(definition, t.category) : 'custom',
|
|
108
|
+
category: t.category.filter((c) => c !== 'Official'),
|
|
109
|
+
vram_gb: t.vramRequirementGb ?? (vrams.length ? Math.min(...vrams) : null),
|
|
110
|
+
needs_variant: variants.length > 0,
|
|
111
|
+
variants,
|
|
112
|
+
exposes_port: definition ? exposesPorts(definition) : undefined,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function workloadFacts(resolved, definition) {
|
|
116
|
+
const hints = resolved ? hardwareHints(resolved.parent) : { blackwellOnly: false, minDriver: null, notes: [] };
|
|
117
|
+
const kind = kindOfDefinition(definition, resolved?.parent.category ?? []);
|
|
118
|
+
return {
|
|
119
|
+
kind,
|
|
120
|
+
minVramGb: resolved ? resolved.template.vramRequirementGb : vramFromDefinition(definition),
|
|
121
|
+
blackwellOnly: hints.blackwellOnly,
|
|
122
|
+
hardwareNotes: hints.notes,
|
|
123
|
+
bootMinutes: typicalBootMinutes(definition, kind),
|
|
124
|
+
exposes: exposesPorts(definition),
|
|
125
|
+
opId: primaryOpId(definition),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
const fitRequirements = (facts) => ({
|
|
129
|
+
minVramGb: facts.minVramGb,
|
|
130
|
+
blackwellOnly: facts.blackwellOnly,
|
|
131
|
+
isBlackwell: (m) => isBlackwell(m.name, m.slug),
|
|
132
|
+
});
|
|
133
|
+
const fitOptions = (facts) => ({
|
|
134
|
+
minVramGb: facts.minVramGb,
|
|
135
|
+
recommend: facts.blackwellOnly ? (m) => isBlackwell(m.name, m.slug) : undefined,
|
|
136
|
+
});
|
|
137
|
+
const squash = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '');
|
|
138
|
+
function lookupWorkload(all, workload, variant) {
|
|
139
|
+
let templateQuery = workload.trim();
|
|
140
|
+
let variantQuery = variant;
|
|
141
|
+
if (!variantQuery && templateQuery.includes('/')) {
|
|
142
|
+
const [t, v] = templateQuery.split('/', 2);
|
|
143
|
+
templateQuery = t.trim();
|
|
144
|
+
variantQuery = v.trim();
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
return { resolved: resolveTemplate(all, templateQuery, variantQuery) };
|
|
148
|
+
}
|
|
149
|
+
catch (error) {
|
|
150
|
+
if (error instanceof NeedsVariantError)
|
|
151
|
+
return { needsVariant: error.template };
|
|
152
|
+
if (!(error instanceof CliError) || !error.message.startsWith('Unknown template'))
|
|
153
|
+
throw error;
|
|
154
|
+
}
|
|
155
|
+
// Fuzzy: every token must appear in the id or name, e.g. "qwen 27b" -> qwen3-5-27b, qwen3-6-27b.
|
|
156
|
+
const tokens = workload.toLowerCase().split(/[^a-z0-9.]+/).filter(Boolean).map(squash).filter(Boolean);
|
|
157
|
+
const matches = all.filter((t) => {
|
|
158
|
+
const hay = squash(`${t.id} ${t.name}`);
|
|
159
|
+
return tokens.every((tok) => hay.includes(tok));
|
|
160
|
+
});
|
|
161
|
+
const deployable = matches.filter((t) => t.jobDefinition);
|
|
162
|
+
if (deployable.length === 1)
|
|
163
|
+
return { resolved: resolveTemplate(all, deployable[0].id) };
|
|
164
|
+
const parents = matches.filter((t) => !t.isVariant);
|
|
165
|
+
if (deployable.length === 0 && parents.length === 1)
|
|
166
|
+
return { needsVariant: parents[0] };
|
|
167
|
+
return { candidates: (deployable.length ? deployable : matches).slice(0, 8) };
|
|
168
|
+
}
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Plans
|
|
171
|
+
const planShape = {
|
|
172
|
+
template: z.string().optional().describe('Template id or name, e.g. "minimax-h3" or "minimax-h3/i2v-32gb" (see list_templates or recommend_plan). Omit when passing job_definition.'),
|
|
173
|
+
variant: z.string().optional().describe('Variant id, e.g. "i2v-32gb". Required when the template has variants.'),
|
|
174
|
+
job_definition: z.record(z.string(), z.unknown()).optional().describe('A custom Nosana job definition object instead of a template.'),
|
|
175
|
+
gpu: z.string().default('auto').describe('GPU market slug, short name or address ("nvidia-5090", "5090"), or "auto" for the cheapest GPU that fits and has an idle host right now.'),
|
|
176
|
+
timeout_minutes: z
|
|
177
|
+
.number()
|
|
178
|
+
.int()
|
|
179
|
+
.min(MIN_TIMEOUT_MINUTES)
|
|
180
|
+
.optional()
|
|
181
|
+
.describe(`Minutes the GPU is reserved per job. Minimum ${MIN_TIMEOUT_MINUTES}. Default: 120 for workloads that download weights, else 60.`),
|
|
182
|
+
replicas: z.number().int().min(1).default(1).describe('Parallel jobs.'),
|
|
183
|
+
strategy: z
|
|
184
|
+
.enum(['SIMPLE', 'SIMPLE-EXTEND', 'SCHEDULED', 'INFINITE'])
|
|
185
|
+
.default('SIMPLE')
|
|
186
|
+
.describe('SIMPLE runs once and stops at the timeout (predictable cost). SIMPLE-EXTEND keeps extending while credits last. SCHEDULED needs schedule. INFINITE keeps a replacement job ready.'),
|
|
187
|
+
schedule: z.string().optional().describe('Cron expression (5 fields). SCHEDULED strategy only.'),
|
|
188
|
+
include_community_gpus: z.boolean().default(false).describe('Allow community GPU markets, not only the premium ones the dashboard shows.'),
|
|
189
|
+
};
|
|
190
|
+
class NoReadyGpuError extends Error {
|
|
191
|
+
buckets;
|
|
192
|
+
facts;
|
|
193
|
+
resolved;
|
|
194
|
+
constructor(buckets, facts, resolved) {
|
|
195
|
+
super('No GPU that fits this workload has an idle host right now.');
|
|
196
|
+
this.buckets = buckets;
|
|
197
|
+
this.facts = facts;
|
|
198
|
+
this.resolved = resolved;
|
|
199
|
+
this.name = 'NoReadyGpuError';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
async function resolveWorkloadForPlan(ctx, input) {
|
|
203
|
+
if (!input.template && !input.job_definition)
|
|
204
|
+
throw new CliError('Pass template (plus variant when needed) or job_definition.', 2);
|
|
205
|
+
if (input.template && input.job_definition)
|
|
206
|
+
throw new CliError('Pass either template or job_definition, not both.', 2);
|
|
207
|
+
if (input.template) {
|
|
208
|
+
const lookup = lookupWorkload(await ctx.templates(), input.template, input.variant);
|
|
209
|
+
if (lookup.resolved)
|
|
210
|
+
return { resolved: lookup.resolved, definition: prepareJobDefinition(lookup.resolved.jobDefinition, 'mcp'), label: lookup.resolved.id };
|
|
211
|
+
if (lookup.needsVariant)
|
|
212
|
+
throw new NeedsVariantError(lookup.needsVariant);
|
|
213
|
+
throw new CliError(`Ambiguous or unknown workload "${input.template}". Candidates: ${(lookup.candidates ?? []).map((c) => c.id).join(', ') || 'none'}. Use list_templates or recommend_plan.`, 2);
|
|
214
|
+
}
|
|
215
|
+
const validation = validateJobDefinition(input.job_definition);
|
|
216
|
+
if (!validation.success)
|
|
217
|
+
throw new CliError(`Invalid job definition: ${JSON.stringify(validation.errors)}`, 2);
|
|
218
|
+
return { resolved: null, definition: prepareJobDefinition(input.job_definition, 'mcp'), label: 'custom-job' };
|
|
219
|
+
}
|
|
220
|
+
async function preparePlan(ctx, input, options) {
|
|
221
|
+
const client = ctx.get();
|
|
222
|
+
const [{ resolved, definition, label }, balance, catalog] = await Promise.all([
|
|
223
|
+
resolveWorkloadForPlan(ctx, input),
|
|
224
|
+
withRetry(() => client.api.credits.balance()),
|
|
225
|
+
ctx.catalog(input.include_community_gpus),
|
|
226
|
+
]);
|
|
227
|
+
const facts = workloadFacts(resolved, definition);
|
|
228
|
+
const buckets = bucketGpus(catalog, fitRequirements(facts));
|
|
229
|
+
let gpu;
|
|
230
|
+
let autoPicked = false;
|
|
231
|
+
if (input.gpu.trim().toLowerCase() === 'auto') {
|
|
232
|
+
gpu = autoPickGpu(buckets) ?? undefined;
|
|
233
|
+
autoPicked = true;
|
|
234
|
+
if (!gpu)
|
|
235
|
+
throw new NoReadyGpuError(buckets, facts, resolved);
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
gpu = resolveGpu(catalog, input.gpu);
|
|
239
|
+
if (!gpu)
|
|
240
|
+
throw new CliError(`Unknown GPU "${input.gpu}". Use "auto" or a slug from list_gpus${input.include_community_gpus ? '' : ' (set include_community_gpus for community markets)'}.`, 2);
|
|
241
|
+
}
|
|
242
|
+
const timeoutMinutes = input.timeout_minutes ?? (facts.bootMinutes >= 10 ? 120 : MIN_TIMEOUT_MINUTES);
|
|
243
|
+
const plan = {
|
|
244
|
+
name: options.name?.trim() || defaultDeploymentName(label),
|
|
245
|
+
workload: label,
|
|
246
|
+
jobDefinition: definition,
|
|
247
|
+
gpu,
|
|
248
|
+
timeoutMinutes,
|
|
249
|
+
replicas: input.replicas,
|
|
250
|
+
strategy: parseStrategy(input.strategy),
|
|
251
|
+
schedule: input.schedule,
|
|
252
|
+
confidential: Boolean(options.confidential),
|
|
253
|
+
};
|
|
254
|
+
validatePlan(plan);
|
|
255
|
+
const creditsAvailable = availableCredits(balance);
|
|
256
|
+
const assessment = assessPlan(plan, fitOptions(facts), creditsAvailable, { requireIdle: options.requireIdle });
|
|
257
|
+
return { plan, resolved, facts, buckets, assessment, creditsAvailable, autoPicked };
|
|
258
|
+
}
|
|
259
|
+
function planView(prepared) {
|
|
260
|
+
const { plan, resolved, facts, assessment, creditsAvailable } = prepared;
|
|
261
|
+
return {
|
|
262
|
+
name: plan.name,
|
|
263
|
+
workload: resolved
|
|
264
|
+
? { template: resolved.parent.id, variant: resolved.variant?.id ?? null, title: resolved.template.name, kind: facts.kind }
|
|
265
|
+
: { custom_job_definition: true, kind: facts.kind },
|
|
266
|
+
gpu: gpuView(plan.gpu),
|
|
267
|
+
gpu_auto_selected: prepared.autoPicked,
|
|
268
|
+
strategy: plan.strategy,
|
|
269
|
+
replicas: plan.replicas,
|
|
270
|
+
timeout_minutes: plan.timeoutMinutes,
|
|
271
|
+
usd_per_hour: round(plan.gpu.pricePerHour * plan.replicas),
|
|
272
|
+
estimated_credits: round(assessment.cost.total),
|
|
273
|
+
boot_minutes_typical: facts.bootMinutes,
|
|
274
|
+
credits_available: round(creditsAvailable),
|
|
275
|
+
hardware_notes: facts.hardwareNotes,
|
|
276
|
+
warnings: assessment.warnings,
|
|
277
|
+
blocking: assessment.blocking,
|
|
278
|
+
deployable: assessment.blocking.length === 0,
|
|
279
|
+
billing: BILLING_NOTES,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
function noReadyEnvelope(error, timeoutMinutes, templateArgs) {
|
|
283
|
+
const b = error.buckets;
|
|
284
|
+
const queued = b.fits_but_queued[0]?.market;
|
|
285
|
+
const risky = b.idle_with_risk[0]?.market;
|
|
286
|
+
const options = [];
|
|
287
|
+
if (queued)
|
|
288
|
+
options.push(`wait for ${queued.name} (${queued.slug}, ${round(queued.pricePerHour)} USD/h, 0 idle): create_deployment with gpu="${queued.slug}" and accept_queue=true`);
|
|
289
|
+
if (risky)
|
|
290
|
+
options.push(`use idle ${risky.name} (${risky.slug}) despite "${b.idle_with_risk[0].risks.join('; ')}": create_deployment with gpu="${risky.slug}" and force=true`);
|
|
291
|
+
return {
|
|
292
|
+
ok: true,
|
|
293
|
+
message: `No GPU that fits has an idle host right now. Ask the user which they prefer: ${options.join(' OR ') || 'try again later'}. ${b.unsupported.length ? `${b.unsupported.length} idle-or-not GPUs are unsupported for this workload and are not offered.` : ''}`.trim(),
|
|
294
|
+
outcome: 'needs_decision',
|
|
295
|
+
gpus: bucketsView(b),
|
|
296
|
+
workload: { kind: error.facts.kind, vram_gb: error.facts.minVramGb, blackwell_only: error.facts.blackwellOnly, hardware_notes: error.facts.hardwareNotes, boot_minutes_typical: error.facts.bootMinutes },
|
|
297
|
+
timeout_minutes: timeoutMinutes ?? (error.facts.bootMinutes >= 10 ? 120 : MIN_TIMEOUT_MINUTES),
|
|
298
|
+
next_tool: 'create_deployment',
|
|
299
|
+
next_args: { ...templateArgs, gpu: queued?.slug ?? risky?.slug ?? 'auto', accept_queue: Boolean(queued), confirm: false },
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
async function runningSimilar(ctx, resolved, opId) {
|
|
303
|
+
try {
|
|
304
|
+
const result = await ctx.get().api.deployments.list({ limit: pageSize(50), status: 'RUNNING,STARTING' });
|
|
305
|
+
const templateId = resolved?.parent.id;
|
|
306
|
+
return result.deployments
|
|
307
|
+
.filter((d) => (templateId && d.name.toLowerCase().startsWith(templateId.toLowerCase())) || (opId && d.endpoints?.some((e) => e.opId === opId)))
|
|
308
|
+
.map((d) => ({ deployment_id: d.id, name: d.name, status: d.status, dashboard_url: dashboardUrl(d.id, ctx.network) }));
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
return [];
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Endpoint usage
|
|
316
|
+
function endpointUsage(snapshot) {
|
|
317
|
+
const { facts, endpoints } = snapshot;
|
|
318
|
+
const primary = endpoints.find((e) => e.service_ready) ?? endpoints[0];
|
|
319
|
+
const base = primary?.url?.replace(/\/$/, '') ?? '<endpoint-url>';
|
|
320
|
+
const ready = Boolean(primary?.service_ready);
|
|
321
|
+
const common = {
|
|
322
|
+
kind: facts.kind,
|
|
323
|
+
ready,
|
|
324
|
+
endpoints: endpoints.map((e) => ({ url: e.url, op: e.op, port: e.port, tunnel_online: e.tunnel_online, service_ready: e.service_ready })),
|
|
325
|
+
stop_hint: 'stop_deployment ends billing when the user is done.',
|
|
326
|
+
};
|
|
327
|
+
switch (facts.kind) {
|
|
328
|
+
case 'comfyui':
|
|
329
|
+
return {
|
|
330
|
+
...common,
|
|
331
|
+
ui_url: base,
|
|
332
|
+
api: {
|
|
333
|
+
queue_prompt: `POST ${base}/prompt body: {"prompt": <workflow in API format>, "client_id": "<any>"}`,
|
|
334
|
+
history: `GET ${base}/history/<prompt_id>`,
|
|
335
|
+
view_output: `GET ${base}/view?filename=<name>&subfolder=<sub>&type=output`,
|
|
336
|
+
system_stats: `GET ${base}/system_stats`,
|
|
337
|
+
object_info: `GET ${base}/object_info`,
|
|
338
|
+
},
|
|
339
|
+
notes: [
|
|
340
|
+
'In the ComfyUI UI use Workflow > Export (API) to get the JSON shape the /prompt endpoint expects.',
|
|
341
|
+
'Outputs stay on the host; download them via /view before stopping the deployment.',
|
|
342
|
+
facts.opId?.toLowerCase().includes('minimax') ? 'MiniMax H3: build the local-weights graph from get_template include_readme=true; ComfyUI\'s bundled api_minimax_h3_* templates call MiniMax\'s cloud API instead.' : null,
|
|
343
|
+
].filter(Boolean),
|
|
344
|
+
};
|
|
345
|
+
case 'llm': {
|
|
346
|
+
const flavor = facts.jobDefinition ? llmFlavor(facts.jobDefinition) : 'other';
|
|
347
|
+
return {
|
|
348
|
+
...common,
|
|
349
|
+
openai_base_url: `${base}/v1`,
|
|
350
|
+
api_key: 'any non-empty string (the endpoint is public unless the deployment is confidential)',
|
|
351
|
+
list_models: `GET ${base}/v1/models`,
|
|
352
|
+
chat: `POST ${base}/v1/chat/completions body: {"model": "<id from /v1/models>", "messages": [{"role": "user", "content": "..."}]}`,
|
|
353
|
+
env: { OPENAI_BASE_URL: `${base}/v1`, OPENAI_API_KEY: 'nosana' },
|
|
354
|
+
...(flavor === 'ollama' ? { ollama_native: { tags: `GET ${base}/api/tags`, generate: `POST ${base}/api/generate`, chat: `POST ${base}/api/chat` } } : {}),
|
|
355
|
+
notes: ['Model download happens on first start; /v1/models lists the model once it is loaded.', 'Any OpenAI-compatible SDK works by pointing its base URL at openai_base_url.'],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
case 'notebook':
|
|
359
|
+
return { ...common, url: base, notes: ['Jupyter may ask for a token; get_job_result shows the startup log where the token is printed.'] };
|
|
360
|
+
case 'ide':
|
|
361
|
+
return { ...common, url: base, notes: ['Open the URL in a browser. Check get_job_result for a password if the image prints one.'] };
|
|
362
|
+
default:
|
|
363
|
+
return { ...common, urls: endpoints.map((e) => e.url), ports: facts.jobDefinition ? exposedPorts(facts.jobDefinition) : [], notes: ['Open the URL for the port the job exposes.'] };
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
async function usdPerHourForMarket(ctx, market) {
|
|
367
|
+
try {
|
|
368
|
+
const catalog = await ctx.catalog(true);
|
|
369
|
+
const found = catalog.find((m) => m.address === market);
|
|
370
|
+
return found ? round(found.pricePerHour) : null;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
async function snapshotView(ctx, snapshot) {
|
|
377
|
+
const dep = snapshot.deployment;
|
|
378
|
+
const hint = PHASE_HINTS[snapshot.phase];
|
|
379
|
+
const [usd, catalog] = await Promise.all([usdPerHourForMarket(ctx, dep.market), ctx.catalog(true).catch(() => [])]);
|
|
380
|
+
const market = catalog.find((m) => m.address === dep.market);
|
|
381
|
+
const terminal = ['ready', 'completed', 'stopped', 'error', 'draft'].includes(snapshot.phase);
|
|
382
|
+
return {
|
|
383
|
+
deployment_id: dep.id,
|
|
384
|
+
name: dep.name,
|
|
385
|
+
status: dep.status,
|
|
386
|
+
phase: snapshot.phase,
|
|
387
|
+
phase_message: hint.message,
|
|
388
|
+
kind: snapshot.facts.kind,
|
|
389
|
+
gpu: market ? { gpu: market.slug, name: market.name, idle_hosts_on_market: market.availableNodes } : { market_address: dep.market },
|
|
390
|
+
usd_per_hour: usd,
|
|
391
|
+
timeout_minutes: dep.timeout,
|
|
392
|
+
replicas: dep.replicas,
|
|
393
|
+
active_jobs: dep.active_jobs,
|
|
394
|
+
strategy: dep.strategy,
|
|
395
|
+
confidential: dep.confidential,
|
|
396
|
+
elapsed_seconds: snapshot.elapsedSeconds,
|
|
397
|
+
boot_minutes_typical: snapshot.facts.bootMinutes,
|
|
398
|
+
endpoints: snapshot.endpoints,
|
|
399
|
+
ready_urls: snapshot.readyUrls,
|
|
400
|
+
recent_jobs: snapshot.jobs.map((j) => ({ ...j, explorer_url: explorerJobUrl(j.job, ctx.network) })),
|
|
401
|
+
recent_events: snapshot.events,
|
|
402
|
+
dashboard_url: dashboardUrl(dep.id, ctx.network),
|
|
403
|
+
billing: BILLING_NOTES,
|
|
404
|
+
poll_after_seconds: hint.pollAfterSeconds,
|
|
405
|
+
next_tool: terminal ? (snapshot.phase === 'ready' ? 'get_endpoint_usage' : null) : 'wait_for_deployment',
|
|
406
|
+
next_args: terminal ? (snapshot.phase === 'ready' ? { deployment_id: dep.id } : undefined) : { deployment_id: dep.id, max_seconds: WAIT_DEFAULT_SECONDS },
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
// ---------------------------------------------------------------------------
|
|
410
|
+
// Tools
|
|
411
|
+
export const tools = [
|
|
412
|
+
tool('doctor', {
|
|
413
|
+
title: 'Check setup',
|
|
414
|
+
description: 'Checks the API key, credits, and every Nosana API this server uses. Call it first when something fails; it returns the exact fix.',
|
|
415
|
+
annotations: READ_ONLY,
|
|
416
|
+
}, {}, async (ctx) => {
|
|
417
|
+
const checks = [];
|
|
418
|
+
const key = resolveApiKey();
|
|
419
|
+
checks.push({ name: 'api_key', ok: Boolean(key), detail: key ? `${maskKey(key.key)} from ${key.source}` : 'missing' });
|
|
420
|
+
if (!key) {
|
|
421
|
+
return {
|
|
422
|
+
ok: false,
|
|
423
|
+
message: 'No Nosana API key configured.',
|
|
424
|
+
checks,
|
|
425
|
+
fix: [
|
|
426
|
+
'Create a key at https://deploy.nosana.com (Account > API Keys), then add it to the MCP server environment.',
|
|
427
|
+
'Claude Code: claude mcp add nosana --env NOSANA_API_KEY=nos_... -- npx -y nosana-mcp',
|
|
428
|
+
'JSON clients: {"mcpServers":{"nosana":{"command":"npx","args":["-y","nosana-mcp"],"env":{"NOSANA_API_KEY":"nos_..."}}}}',
|
|
429
|
+
'Restart or reconnect the MCP client after changing the configuration.',
|
|
430
|
+
],
|
|
431
|
+
next_tool: null,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
const client = ctx.get();
|
|
435
|
+
const run = async (name, fn) => {
|
|
436
|
+
try {
|
|
437
|
+
checks.push({ name, ok: true, detail: await fn() });
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
checks.push({ name, ok: false, detail: formatError(error) });
|
|
441
|
+
}
|
|
442
|
+
};
|
|
443
|
+
await run('credits_api', async () => `${round(availableCredits(await client.api.credits.balance()))} credits available`);
|
|
444
|
+
await run('markets_api', async () => `${(await client.api.markets.list()).length} GPU markets`);
|
|
445
|
+
await run('templates_api', async () => `${(await ctx.templates()).length} templates`);
|
|
446
|
+
await run('host_availability', async () => `${(await client.api.hosts.getQueuedNodes()).length} idle hosts network-wide`);
|
|
447
|
+
await run('deployments_api', async () => `${(await client.api.deployments.list({ limit: 10 })).total_items} deployments on this account`);
|
|
448
|
+
const failed = checks.filter((c) => !c.ok);
|
|
449
|
+
return {
|
|
450
|
+
ok: failed.length === 0,
|
|
451
|
+
message: failed.length ? `${failed.length} check(s) failed: ${failed.map((c) => c.name).join(', ')}.` : 'All checks passed.',
|
|
452
|
+
checks,
|
|
453
|
+
fix: failed.some((c) => c.name === 'credits_api') ? 'The key was rejected. Create a new key at https://deploy.nosana.com and update the MCP server environment.' : undefined,
|
|
454
|
+
network: ctx.network,
|
|
455
|
+
next_tool: failed.length ? null : 'recommend_plan',
|
|
456
|
+
};
|
|
457
|
+
}),
|
|
458
|
+
tool('get_balance', {
|
|
459
|
+
title: 'Get credit balance',
|
|
460
|
+
description: 'Credits on the Nosana account behind the API key: assigned, reserved by running deployments, settled (spent) and available. 1 credit is priced like 1 USD.',
|
|
461
|
+
annotations: READ_ONLY,
|
|
462
|
+
}, {}, async (ctx) => {
|
|
463
|
+
const b = summarizeBalance(await withRetry(() => ctx.get().api.credits.balance()));
|
|
464
|
+
return {
|
|
465
|
+
ok: true,
|
|
466
|
+
message: `${round(b.available)} credits available.`,
|
|
467
|
+
assigned: round(b.assigned),
|
|
468
|
+
reserved: round(b.reserved),
|
|
469
|
+
settled: round(b.settled),
|
|
470
|
+
available: round(b.available),
|
|
471
|
+
top_up_url: 'https://deploy.nosana.com',
|
|
472
|
+
next_tool: null,
|
|
473
|
+
};
|
|
474
|
+
}),
|
|
475
|
+
tool('list_templates', {
|
|
476
|
+
title: 'List templates',
|
|
477
|
+
description: 'Ready-to-run Nosana templates (MiniMax H3 video, Qwen and Gemma LLMs via Ollama, DeepSeek via vLLM, ComfyUI, Jupyter, VS Code, Whisper, ...) with kind, VRAM needs and variants. Templates with variants need a variant id when deploying.',
|
|
478
|
+
annotations: READ_ONLY,
|
|
479
|
+
}, { search: z.string().optional().describe('Filter by id, name or category, e.g. "minimax", "qwen" or "LLM".') }, async (ctx, { search }) => {
|
|
480
|
+
const all = await ctx.templates();
|
|
481
|
+
let top = topLevelTemplates(all);
|
|
482
|
+
if (search) {
|
|
483
|
+
const q = search.toLowerCase();
|
|
484
|
+
top = top.filter((t) => t.id.toLowerCase().includes(q) || t.name.toLowerCase().includes(q) || t.category.join(' ').toLowerCase().includes(q));
|
|
485
|
+
}
|
|
486
|
+
return { ok: true, message: `${top.length} templates.`, templates: top.map((t) => templateView(t, all)), next_tool: 'recommend_plan' };
|
|
487
|
+
}),
|
|
488
|
+
tool('get_template', {
|
|
489
|
+
title: 'Get template details',
|
|
490
|
+
description: 'Details of one template or variant: kind, VRAM needed, hardware notes (e.g. MiniMax H3 needs a Blackwell GPU), exposed ports, typical boot time, variants, and optionally the job definition and README.',
|
|
491
|
+
annotations: READ_ONLY,
|
|
492
|
+
}, {
|
|
493
|
+
template: z.string().describe('Template id or name, e.g. "minimax-h3" or "minimax-h3/i2v-32gb".'),
|
|
494
|
+
variant: z.string().optional().describe('Variant id, e.g. "i2v-32gb".'),
|
|
495
|
+
include_job_definition: z.boolean().default(false),
|
|
496
|
+
include_readme: z.boolean().default(false),
|
|
497
|
+
}, async (ctx, { template, variant, include_job_definition, include_readme }) => {
|
|
498
|
+
const all = await ctx.templates();
|
|
499
|
+
const lookup = lookupWorkload(all, template, variant);
|
|
500
|
+
if (lookup.candidates) {
|
|
501
|
+
return { ok: false, message: `Ambiguous or unknown template "${template}".`, candidates: lookup.candidates.map((c) => ({ id: c.id, name: c.name })), next_tool: 'list_templates' };
|
|
502
|
+
}
|
|
503
|
+
const parent = lookup.resolved?.parent ?? lookup.needsVariant;
|
|
504
|
+
const target = lookup.resolved?.template ?? parent;
|
|
505
|
+
const definition = lookup.resolved?.jobDefinition ?? null;
|
|
506
|
+
const facts = definition ? workloadFacts(lookup.resolved ?? null, definition) : null;
|
|
507
|
+
const hints = hardwareHints(parent);
|
|
508
|
+
return {
|
|
509
|
+
ok: true,
|
|
510
|
+
message: lookup.resolved ? `${target.name}.` : `${parent.name} has ${parent.variants.length} variants; pick one.`,
|
|
511
|
+
...templateView(parent, all),
|
|
512
|
+
selected_variant: lookup.resolved?.variant?.id ?? null,
|
|
513
|
+
vram_gb: target.vramRequirementGb,
|
|
514
|
+
exposes_port: facts?.exposes,
|
|
515
|
+
ports: definition ? exposedPorts(definition) : undefined,
|
|
516
|
+
boot_minutes_typical: facts?.bootMinutes,
|
|
517
|
+
hardware_notes: hints.notes,
|
|
518
|
+
blackwell_only: hints.blackwellOnly,
|
|
519
|
+
recommended_gpus: hints.blackwellOnly ? ['nvidia-5090', 'nvidia-pro6000'] : undefined,
|
|
520
|
+
minimum_timeout_minutes: MIN_TIMEOUT_MINUTES,
|
|
521
|
+
job_definition: include_job_definition && lookup.resolved ? prepareJobDefinition(lookup.resolved.jobDefinition, 'mcp') : undefined,
|
|
522
|
+
readme: include_readme ? parent.readme : undefined,
|
|
523
|
+
next_tool: lookup.resolved ? 'recommend_plan' : 'get_template',
|
|
524
|
+
next_args: lookup.resolved ? { workload: lookup.resolved.id } : { template: parent.id, variant: parent.variants[0]?.id },
|
|
525
|
+
};
|
|
526
|
+
}),
|
|
527
|
+
tool('list_gpus', {
|
|
528
|
+
title: 'List GPU markets',
|
|
529
|
+
description: 'GPU markets bucketed the way a buyer shops: ready_now (fits and idle host available), fits_but_queued (fits, nobody idle), idle_with_risk (idle but a caveat), unsupported (cannot run this workload). Prices per hour include the network fee and match deploy.nosana.com. Pass a template so the buckets reflect its VRAM and hardware needs.',
|
|
530
|
+
annotations: READ_ONLY,
|
|
531
|
+
}, {
|
|
532
|
+
template: z.string().optional().describe('Template id, optionally "id/variant", to bucket by its requirements.'),
|
|
533
|
+
variant: z.string().optional(),
|
|
534
|
+
min_vram_gb: z.number().optional().describe('Bucket by a VRAM requirement instead of a template.'),
|
|
535
|
+
include_community: z.boolean().default(false).describe('Include community and special markets.'),
|
|
536
|
+
include_queue: z.boolean().default(false).describe('Also read on-chain queues to report jobs waiting per market (slower).'),
|
|
537
|
+
verbose: z.boolean().default(false).describe('Also return the full flat list and too-small GPUs.'),
|
|
538
|
+
}, async (ctx, { template, variant, min_vram_gb, include_community, include_queue, verbose }) => {
|
|
539
|
+
let req = { minVramGb: min_vram_gb ?? null };
|
|
540
|
+
let label = min_vram_gb ? `${min_vram_gb} GB VRAM` : 'any workload';
|
|
541
|
+
if (template) {
|
|
542
|
+
const lookup = lookupWorkload(await ctx.templates(), template, variant);
|
|
543
|
+
if (!lookup.resolved) {
|
|
544
|
+
return { ok: false, message: lookup.needsVariant ? `Template "${template}" needs a variant: ${lookup.needsVariant.variants.map((v) => v.id).join(', ')}.` : `Unknown template "${template}".`, next_tool: 'list_templates' };
|
|
545
|
+
}
|
|
546
|
+
const facts = workloadFacts(lookup.resolved, lookup.resolved.jobDefinition);
|
|
547
|
+
req = { ...fitRequirements(facts), minVramGb: min_vram_gb ?? facts.minVramGb };
|
|
548
|
+
label = lookup.resolved.id;
|
|
549
|
+
}
|
|
550
|
+
const catalog = await ctx.catalog(include_community, include_queue);
|
|
551
|
+
const buckets = bucketGpus(catalog, req);
|
|
552
|
+
return {
|
|
553
|
+
ok: true,
|
|
554
|
+
message: `${buckets.ready_now.length} GPU(s) ready now for ${label}, ${buckets.fits_but_queued.length} fit but have no idle host, ${buckets.idle_with_risk.length} idle with caveats, ${buckets.unsupported.length} unsupported.`,
|
|
555
|
+
fit_for: label,
|
|
556
|
+
...bucketsView(buckets, verbose),
|
|
557
|
+
all: verbose ? catalog.map((m) => gpuView(m)) : undefined,
|
|
558
|
+
next_tool: 'recommend_plan',
|
|
559
|
+
};
|
|
560
|
+
}),
|
|
561
|
+
tool('recommend_plan', {
|
|
562
|
+
title: 'Recommend a deployment plan',
|
|
563
|
+
description: 'One call from a workload name to a ready-to-confirm plan. Accepts "minimax-h3", "minimax-h3/i2v-32gb", "qwen3-6-27b", "gemma3 27b", "comfyui/sdxl" or a job_definition. Returns the resolved template, GPUs in buckets, the cheapest GPU that is ready now with its cost, any similar deployment already running, and the exact create_deployment arguments. Never picks a queued or unsupported GPU on its own.',
|
|
564
|
+
annotations: READ_ONLY,
|
|
565
|
+
}, {
|
|
566
|
+
workload: z.string().optional().describe('Template id/name, optionally with "/variant", or a loose description like "qwen 27b".'),
|
|
567
|
+
variant: z.string().optional(),
|
|
568
|
+
job_definition: z.record(z.string(), z.unknown()).optional(),
|
|
569
|
+
timeout_minutes: z.number().int().min(MIN_TIMEOUT_MINUTES).optional(),
|
|
570
|
+
include_community_gpus: z.boolean().default(false),
|
|
571
|
+
}, async (ctx, { workload, variant, job_definition, timeout_minutes, include_community_gpus }) => {
|
|
572
|
+
if (!workload && !job_definition)
|
|
573
|
+
return { ok: false, message: 'Pass workload or job_definition.', next_tool: 'list_templates' };
|
|
574
|
+
let resolved = null;
|
|
575
|
+
let definition;
|
|
576
|
+
if (workload) {
|
|
577
|
+
const lookup = lookupWorkload(await ctx.templates(), workload, variant);
|
|
578
|
+
if (lookup.needsVariant) {
|
|
579
|
+
const t = lookup.needsVariant;
|
|
580
|
+
const all = await ctx.templates();
|
|
581
|
+
return {
|
|
582
|
+
ok: true,
|
|
583
|
+
message: `${t.name} has ${t.variants.length} variants. Ask the user (or pick the smallest that fits their need) and call again with workload "${t.id}/<variant>".`,
|
|
584
|
+
outcome: 'needs_variant',
|
|
585
|
+
template: templateView(t, all),
|
|
586
|
+
next_tool: 'recommend_plan',
|
|
587
|
+
next_args: { workload: `${t.id}/${t.variants[0]?.id}` },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
if (lookup.candidates) {
|
|
591
|
+
return {
|
|
592
|
+
ok: true,
|
|
593
|
+
message: lookup.candidates.length ? `"${workload}" matches several templates; ask the user which one.` : `Nothing matches "${workload}".`,
|
|
594
|
+
outcome: 'needs_choice',
|
|
595
|
+
candidates: lookup.candidates.map((c) => ({ id: c.id, name: c.name, vram_gb: c.vramRequirementGb })),
|
|
596
|
+
next_tool: lookup.candidates.length ? 'recommend_plan' : 'list_templates',
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
resolved = lookup.resolved;
|
|
600
|
+
definition = prepareJobDefinition(resolved.jobDefinition, 'mcp');
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
const validation = validateJobDefinition(job_definition);
|
|
604
|
+
if (!validation.success)
|
|
605
|
+
return { ok: false, message: `Invalid job definition: ${JSON.stringify(validation.errors)}`, next_tool: null };
|
|
606
|
+
definition = prepareJobDefinition(job_definition, 'mcp');
|
|
607
|
+
}
|
|
608
|
+
const facts = workloadFacts(resolved, definition);
|
|
609
|
+
const [catalog, balance, running] = await Promise.all([ctx.catalog(include_community_gpus), withRetry(() => ctx.get().api.credits.balance()), runningSimilar(ctx, resolved, facts.opId)]);
|
|
610
|
+
const buckets = bucketGpus(catalog, fitRequirements(facts));
|
|
611
|
+
const pick = autoPickGpu(buckets);
|
|
612
|
+
const timeout = timeout_minutes ?? (facts.bootMinutes >= 10 ? 120 : MIN_TIMEOUT_MINUTES);
|
|
613
|
+
const credits = availableCredits(balance);
|
|
614
|
+
const templateArgs = resolved ? { template: resolved.parent.id, variant: resolved.variant?.id ?? undefined } : { job_definition };
|
|
615
|
+
const estimated = pick ? round(pick.pricePerHour * (timeout / 60)) : null;
|
|
616
|
+
const affordable = estimated === null || estimated <= credits;
|
|
617
|
+
return {
|
|
618
|
+
ok: true,
|
|
619
|
+
message: pick
|
|
620
|
+
? `Recommended: ${pick.name} (${pick.slug}) at ${round(pick.pricePerHour)} USD/h, ${pick.availableNodes} idle host(s). About ${estimated} credits for ${timeout} minutes; ${round(credits)} available.${affordable ? '' : ' NOT ENOUGH CREDITS.'}${running.length ? ` Note: ${running.length} similar deployment(s) already running.` : ''} Show this to the user, then call create_deployment with create_args and confirm=true.`
|
|
621
|
+
: `Nothing that fits ${resolved?.id ?? 'this job'} has an idle host right now. Options: wait on a fits_but_queued GPU (accept_queue=true) or accept a caveat on an idle_with_risk GPU (force=true). Unsupported GPUs are excluded.`,
|
|
622
|
+
outcome: pick ? 'ready' : 'needs_decision',
|
|
623
|
+
workload: {
|
|
624
|
+
template: resolved?.parent.id ?? null,
|
|
625
|
+
variant: resolved?.variant?.id ?? null,
|
|
626
|
+
title: resolved?.template.name ?? 'custom job definition',
|
|
627
|
+
kind: facts.kind,
|
|
628
|
+
vram_gb: facts.minVramGb,
|
|
629
|
+
blackwell_only: facts.blackwellOnly,
|
|
630
|
+
exposes_port: facts.exposes,
|
|
631
|
+
boot_minutes_typical: facts.bootMinutes,
|
|
632
|
+
hardware_notes: facts.hardwareNotes,
|
|
633
|
+
},
|
|
634
|
+
gpus: bucketsView(buckets),
|
|
635
|
+
recommended: pick ? { ...gpuView(pick), timeout_minutes: timeout, estimated_credits: estimated, affordable } : null,
|
|
636
|
+
credits_available: round(credits),
|
|
637
|
+
running_similar: running,
|
|
638
|
+
billing: BILLING_NOTES,
|
|
639
|
+
create_args: pick ? { ...templateArgs, gpu: pick.slug, timeout_minutes: timeout, confirm: false } : null,
|
|
640
|
+
next_tool: 'create_deployment',
|
|
641
|
+
next_args: pick
|
|
642
|
+
? { ...templateArgs, gpu: pick.slug, timeout_minutes: timeout, confirm: false }
|
|
643
|
+
: { ...templateArgs, gpu: buckets.fits_but_queued[0]?.market.slug ?? buckets.idle_with_risk[0]?.market.slug ?? 'auto', timeout_minutes: timeout, accept_queue: Boolean(buckets.fits_but_queued[0]), confirm: false },
|
|
644
|
+
};
|
|
645
|
+
}),
|
|
646
|
+
tool('estimate_deployment', {
|
|
647
|
+
title: 'Estimate a deployment',
|
|
648
|
+
description: 'Dry run of create_deployment with the same arguments: resolves workload and GPU (including gpu="auto"), validates, and returns the cost for one timeout window plus blocking warnings. Nothing is created.',
|
|
649
|
+
annotations: READ_ONLY,
|
|
650
|
+
}, { ...planShape, accept_queue: z.boolean().default(false).describe('Treat "no idle host" as acceptable.') }, async (ctx, input) => {
|
|
651
|
+
try {
|
|
652
|
+
const prepared = await preparePlan(ctx, input, { requireIdle: !input.accept_queue });
|
|
653
|
+
const view = planView(prepared);
|
|
654
|
+
return {
|
|
655
|
+
ok: true,
|
|
656
|
+
message: view.deployable ? `Deployable: about ${String(view.estimated_credits)} credits for ${prepared.plan.timeoutMinutes} minutes on ${prepared.plan.gpu.name}.` : `Not deployable as-is: ${prepared.assessment.blocking.join(' ')}`,
|
|
657
|
+
...view,
|
|
658
|
+
next_tool: 'create_deployment',
|
|
659
|
+
next_args: { ...input, gpu: prepared.plan.gpu.slug, timeout_minutes: prepared.plan.timeoutMinutes, confirm: false },
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
catch (error) {
|
|
663
|
+
if (error instanceof NoReadyGpuError)
|
|
664
|
+
return noReadyEnvelope(error, input.timeout_minutes, { template: input.template, variant: input.variant, job_definition: input.job_definition });
|
|
665
|
+
throw error;
|
|
666
|
+
}
|
|
667
|
+
}),
|
|
668
|
+
tool('create_deployment', {
|
|
669
|
+
title: 'Create (and start) a deployment',
|
|
670
|
+
description: 'SPENDS CREDITS. Creates a Nosana deployment from a template or job definition on the chosen GPU (or gpu="auto" = cheapest fitting GPU with an idle host) and starts it. Requires confirm=true, which you must only pass after the user has seen the estimate and agreed. Refuses when no host is idle unless accept_queue=true, when a similar deployment is already running unless allow_duplicate=true, and on hardware or credit problems unless force=true. Then poll wait_for_deployment.',
|
|
671
|
+
annotations: SPENDS,
|
|
672
|
+
}, {
|
|
673
|
+
...planShape,
|
|
674
|
+
name: z.string().optional().describe('Deployment name (default: <template>-<timestamp>).'),
|
|
675
|
+
confidential: z.boolean().default(false).describe('Hide the job on the explorer and protect the endpoint with an auth header.'),
|
|
676
|
+
start: z.boolean().default(true).describe('Start immediately. false leaves a DRAFT (drafts cannot be deleted until started once).'),
|
|
677
|
+
confirm: z.boolean().default(false).describe('Must be true. Confirms the user approved the estimated cost.'),
|
|
678
|
+
accept_queue: z.boolean().default(false).describe('The user agrees to wait for a host when the GPU has none idle.'),
|
|
679
|
+
allow_duplicate: z.boolean().default(false).describe('Create even though a similar deployment is already running.'),
|
|
680
|
+
force: z.boolean().default(false).describe('Deploy despite blocking warnings (too little VRAM, unsupported GPU, insufficient credits). Unsupported hardware will most likely fail and still bill the boot time.'),
|
|
681
|
+
}, async (ctx, input) => {
|
|
682
|
+
let prepared;
|
|
683
|
+
try {
|
|
684
|
+
prepared = await preparePlan(ctx, input, { name: input.name, confidential: input.confidential, requireIdle: !input.accept_queue });
|
|
685
|
+
}
|
|
686
|
+
catch (error) {
|
|
687
|
+
if (error instanceof NoReadyGpuError)
|
|
688
|
+
return noReadyEnvelope(error, input.timeout_minutes, { template: input.template, variant: input.variant, job_definition: input.job_definition });
|
|
689
|
+
throw error;
|
|
690
|
+
}
|
|
691
|
+
const view = planView(prepared);
|
|
692
|
+
const running = await runningSimilar(ctx, prepared.resolved, prepared.facts.opId);
|
|
693
|
+
const blocking = [...prepared.assessment.blocking];
|
|
694
|
+
if (running.length && !input.allow_duplicate) {
|
|
695
|
+
blocking.push(`A similar deployment is already running (${running.map((r) => `${r.name} ${r.deployment_id}`).join(', ')}). Reuse it, stop it, or pass allow_duplicate=true.`);
|
|
696
|
+
}
|
|
697
|
+
const base = { ...view, running_similar: running, blocking };
|
|
698
|
+
if (input.confirm !== true) {
|
|
699
|
+
return { ok: true, outcome: 'not_created', message: 'NOT CREATED: confirm is false. Show the estimate to the user; call again with confirm=true once they agree.', ...base, next_tool: 'create_deployment', next_args: { ...input, gpu: prepared.plan.gpu.slug, timeout_minutes: prepared.plan.timeoutMinutes, confirm: true } };
|
|
700
|
+
}
|
|
701
|
+
const onlyQueue = blocking.length > 0 && blocking.every((b) => b.startsWith('No idle'));
|
|
702
|
+
const onlyDuplicate = blocking.length > 0 && blocking.every((b) => b.startsWith('A similar deployment'));
|
|
703
|
+
if (blocking.length && !input.force && !(onlyQueue && input.accept_queue) && !(onlyDuplicate && input.allow_duplicate)) {
|
|
704
|
+
return { ok: true, outcome: 'not_created', message: `NOT CREATED: ${blocking.join(' ')}`, ...base, next_tool: 'create_deployment', next_args: { ...input, gpu: prepared.plan.gpu.slug } };
|
|
705
|
+
}
|
|
706
|
+
const dep = await createDeployment(ctx.get(), prepared.plan);
|
|
707
|
+
if (input.start)
|
|
708
|
+
await dep.start();
|
|
709
|
+
return {
|
|
710
|
+
ok: true,
|
|
711
|
+
outcome: input.start ? 'started' : 'draft',
|
|
712
|
+
message: `${input.start ? 'Created and started' : 'Created as draft'} ${dep.id} on ${prepared.plan.gpu.name}. ${input.start ? `Poll wait_for_deployment until outcome is ${prepared.facts.exposes ? '"online"' : '"completed"'}; typical boot ${prepared.facts.bootMinutes} min.` : 'Start it with start_deployment.'}`,
|
|
713
|
+
deployment_id: dep.id,
|
|
714
|
+
name: dep.name,
|
|
715
|
+
status: input.start ? 'STARTING' : dep.status,
|
|
716
|
+
kind: prepared.facts.kind,
|
|
717
|
+
gpu: gpuView(prepared.plan.gpu),
|
|
718
|
+
usd_per_hour: view.usd_per_hour,
|
|
719
|
+
estimated_credits: view.estimated_credits,
|
|
720
|
+
timeout_minutes: prepared.plan.timeoutMinutes,
|
|
721
|
+
boot_minutes_typical: prepared.facts.bootMinutes,
|
|
722
|
+
warnings: prepared.assessment.warnings,
|
|
723
|
+
dashboard_url: dashboardUrl(dep.id, ctx.network),
|
|
724
|
+
billing: BILLING_NOTES,
|
|
725
|
+
stop_hint: `stop_deployment(${dep.id}) ends billing.`,
|
|
726
|
+
poll_after_seconds: 10,
|
|
727
|
+
next_tool: input.start ? 'wait_for_deployment' : 'start_deployment',
|
|
728
|
+
next_args: input.start ? { deployment_id: dep.id, max_seconds: WAIT_DEFAULT_SECONDS } : { deployment_id: dep.id, confirm: false },
|
|
729
|
+
};
|
|
730
|
+
}),
|
|
731
|
+
tool('wait_for_deployment', {
|
|
732
|
+
title: 'Wait for a deployment',
|
|
733
|
+
description: `Watches a deployment for up to max_seconds (default ${WAIT_DEFAULT_SECONDS}, max ${WAIT_MAX_SECONDS}; MCP clients time out at 60 s). Returns outcome "online" (service answers, with ready URLs and usage), "completed" (job finished, with logs), "stopped" (stopped by the user or its timeout; not an error), "failed" (with the scheduler error) or "pending" (with phase, elapsed time and poll_after_seconds; call again). Safe to call repeatedly.`,
|
|
734
|
+
annotations: READ_ONLY,
|
|
735
|
+
}, {
|
|
736
|
+
deployment_id: z.string(),
|
|
737
|
+
max_seconds: z.number().int().min(5).max(WAIT_MAX_SECONDS).default(WAIT_DEFAULT_SECONDS),
|
|
738
|
+
}, async (ctx, { deployment_id, max_seconds }) => {
|
|
739
|
+
const client = ctx.get();
|
|
740
|
+
const lines = [];
|
|
741
|
+
const outcome = await waitForDeployment(client, deployment_id, {
|
|
742
|
+
timeoutMinutes: max_seconds / 60,
|
|
743
|
+
intervalSeconds: 5,
|
|
744
|
+
network: ctx.network,
|
|
745
|
+
onLog: (line) => lines.push(stripAnsi(line)),
|
|
746
|
+
});
|
|
747
|
+
const dep = outcome.deployment;
|
|
748
|
+
const common = { deployment_id, status: dep.status, dashboard_url: dashboardUrl(dep.id, ctx.network), progress: lines };
|
|
749
|
+
switch (outcome.kind) {
|
|
750
|
+
case 'online': {
|
|
751
|
+
const snapshot = await snapshotDeployment(client, deployment_id);
|
|
752
|
+
const usd = await usdPerHourForMarket(ctx, dep.market);
|
|
753
|
+
return {
|
|
754
|
+
ok: true,
|
|
755
|
+
outcome: 'online',
|
|
756
|
+
message: `ONLINE: the service answers at ${outcome.readyUrls.join(', ')}. Remind the user that it bills ${usd ?? '?'} USD/h until stop_deployment.`,
|
|
757
|
+
ready_urls: outcome.readyUrls,
|
|
758
|
+
kind: snapshot.facts.kind,
|
|
759
|
+
usage: endpointUsage(snapshot),
|
|
760
|
+
usd_per_hour: usd,
|
|
761
|
+
timeout_minutes: dep.timeout,
|
|
762
|
+
billing: BILLING_NOTES,
|
|
763
|
+
stop_hint: `stop_deployment(${dep.id}) ends billing.`,
|
|
764
|
+
...common,
|
|
765
|
+
next_tool: null,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
case 'completed': {
|
|
769
|
+
let result = null;
|
|
770
|
+
try {
|
|
771
|
+
result = (await dep.getJob(outcome.job)).jobResult;
|
|
772
|
+
}
|
|
773
|
+
catch {
|
|
774
|
+
/* results may lag */
|
|
775
|
+
}
|
|
776
|
+
return { ok: true, outcome: 'completed', message: `COMPLETED: job ${outcome.job} finished.`, job: outcome.job, explorer_url: explorerJobUrl(outcome.job, ctx.network), logs: formatJobResult(result).map(stripAnsi), ...common, next_tool: null };
|
|
777
|
+
}
|
|
778
|
+
case 'stopped':
|
|
779
|
+
return { ok: true, outcome: 'stopped', message: 'STOPPED: the deployment was stopped by the user or reached its timeout. This is not a failure; do not restart it unless the user asks. start_deployment would pay the boot time again.', ...common, next_tool: null };
|
|
780
|
+
case 'failed':
|
|
781
|
+
return {
|
|
782
|
+
ok: true,
|
|
783
|
+
outcome: 'failed',
|
|
784
|
+
message: `FAILED: ${outcome.error ?? outcome.reason}.${outcome.error ? ` Call stop_deployment(${dep.id}) so the scheduler stops retrying.` : ''}`,
|
|
785
|
+
reason: outcome.reason,
|
|
786
|
+
error: outcome.error ?? null,
|
|
787
|
+
...common,
|
|
788
|
+
next_tool: outcome.error ? 'stop_deployment' : 'get_deployment_events',
|
|
789
|
+
next_args: { deployment_id },
|
|
790
|
+
};
|
|
791
|
+
default: {
|
|
792
|
+
const snapshot = await snapshotDeployment(client, deployment_id);
|
|
793
|
+
const view = await snapshotView(ctx, snapshot);
|
|
794
|
+
const hint = PHASE_HINTS[snapshot.phase];
|
|
795
|
+
return {
|
|
796
|
+
ok: true,
|
|
797
|
+
outcome: 'pending',
|
|
798
|
+
message: `PENDING (${snapshot.phase}): ${hint.message} ${snapshot.elapsedSeconds}s elapsed since the host took the job; typical boot ${snapshot.facts.bootMinutes} min. Tell the user, then call wait_for_deployment again after ${hint.pollAfterSeconds}s.`,
|
|
799
|
+
...view,
|
|
800
|
+
progress: lines,
|
|
801
|
+
next_tool: 'wait_for_deployment',
|
|
802
|
+
next_args: { deployment_id, max_seconds: WAIT_DEFAULT_SECONDS },
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}),
|
|
807
|
+
tool('get_deployment', {
|
|
808
|
+
title: 'Get deployment',
|
|
809
|
+
description: 'Current status and phase of a deployment: endpoints (tunnel online and whether the service actually answers), ready URLs, price per hour, recent jobs and events, and what to call next.',
|
|
810
|
+
annotations: READ_ONLY,
|
|
811
|
+
}, { deployment_id: z.string() }, async (ctx, { deployment_id }) => {
|
|
812
|
+
const snapshot = await snapshotDeployment(ctx.get(), deployment_id);
|
|
813
|
+
const view = await snapshotView(ctx, snapshot);
|
|
814
|
+
return { ok: true, message: `${snapshot.deployment.name}: ${snapshot.deployment.status}, phase ${snapshot.phase}. ${PHASE_HINTS[snapshot.phase].message}`, ...view };
|
|
815
|
+
}),
|
|
816
|
+
tool('get_endpoint_usage', {
|
|
817
|
+
title: 'How to use a deployment endpoint',
|
|
818
|
+
description: 'Explains how to use a running deployment: ComfyUI UI and /prompt API, OpenAI-compatible base URL for Ollama/vLLM LLMs (OPENAI_BASE_URL, /v1/models, /v1/chat/completions), Jupyter/VS Code URLs, or the raw ports of a custom job.',
|
|
819
|
+
annotations: READ_ONLY,
|
|
820
|
+
}, { deployment_id: z.string() }, async (ctx, { deployment_id }) => {
|
|
821
|
+
const snapshot = await snapshotDeployment(ctx.get(), deployment_id);
|
|
822
|
+
const usage = endpointUsage(snapshot);
|
|
823
|
+
return {
|
|
824
|
+
ok: true,
|
|
825
|
+
message: usage.ready ? `Service is up (${snapshot.facts.kind}).` : `Service is not answering yet (phase ${snapshot.phase}); the usage below applies once it is.`,
|
|
826
|
+
deployment_id,
|
|
827
|
+
phase: snapshot.phase,
|
|
828
|
+
...usage,
|
|
829
|
+
next_tool: usage.ready ? null : 'wait_for_deployment',
|
|
830
|
+
next_args: usage.ready ? undefined : { deployment_id, max_seconds: WAIT_DEFAULT_SECONDS },
|
|
831
|
+
};
|
|
832
|
+
}),
|
|
833
|
+
tool('list_deployments', {
|
|
834
|
+
title: 'List deployments',
|
|
835
|
+
description: 'Deployments on this account (newest first) with status, strategy, active jobs and timeout. Check status=RUNNING before creating another one.',
|
|
836
|
+
annotations: READ_ONLY,
|
|
837
|
+
}, {
|
|
838
|
+
limit: z.number().int().min(1).max(100).default(20),
|
|
839
|
+
status: z.string().optional().describe('Filter, e.g. "RUNNING" or "RUNNING,STARTING" or "STOPPED,ERROR".'),
|
|
840
|
+
search: z.string().optional().describe('Partial id or name.'),
|
|
841
|
+
}, async (ctx, { limit, status, search }) => {
|
|
842
|
+
const result = await withRetry(() => ctx.get().api.deployments.list({
|
|
843
|
+
limit: pageSize(limit),
|
|
844
|
+
...(status ? { status: status.toUpperCase() } : {}),
|
|
845
|
+
...(search ? { search } : {}),
|
|
846
|
+
}));
|
|
847
|
+
const deployments = result.deployments.slice(0, limit).map((d) => ({
|
|
848
|
+
deployment_id: d.id,
|
|
849
|
+
name: d.name,
|
|
850
|
+
status: d.status,
|
|
851
|
+
strategy: d.strategy,
|
|
852
|
+
active_jobs: d.active_jobs,
|
|
853
|
+
timeout_minutes: d.timeout,
|
|
854
|
+
endpoints: (d.endpoints ?? []).map((e) => ({ url: e.url, tunnel_online: e.online })),
|
|
855
|
+
updated_at: d.updated_at,
|
|
856
|
+
dashboard_url: dashboardUrl(d.id, ctx.network),
|
|
857
|
+
}));
|
|
858
|
+
const running = deployments.filter((d) => d.status === 'RUNNING' || d.status === 'STARTING');
|
|
859
|
+
return { ok: true, message: `${deployments.length} of ${result.total_items} deployments; ${running.length} running.`, deployments, running_count: running.length, next_tool: null };
|
|
860
|
+
}),
|
|
861
|
+
tool('stop_deployment', {
|
|
862
|
+
title: 'Stop deployment',
|
|
863
|
+
description: 'Stops a deployment and its running jobs. Billing stops with them. Use when the user is done or when a deployment can never schedule.',
|
|
864
|
+
annotations: DESTRUCTIVE,
|
|
865
|
+
}, { deployment_id: z.string() }, async (ctx, { deployment_id }) => {
|
|
866
|
+
const client = ctx.get();
|
|
867
|
+
const dep = (await client.api.deployments.get(deployment_id));
|
|
868
|
+
if (dep.status === 'DRAFT')
|
|
869
|
+
return { ok: false, message: 'Drafts cannot be stopped (nothing is running). Start it first if you want to run it.', deployment_id, status: dep.status, next_tool: null };
|
|
870
|
+
if (['STOPPED', 'ARCHIVED'].includes(dep.status))
|
|
871
|
+
return { ok: true, message: `Already ${dep.status}.`, deployment_id, status: dep.status, next_tool: null };
|
|
872
|
+
await dep.stop();
|
|
873
|
+
const after = (await client.api.deployments.get(deployment_id));
|
|
874
|
+
return { ok: true, message: `Stop requested; status is now ${after.status}. Billing ends with the job.`, deployment_id, status: after.status, next_tool: null };
|
|
875
|
+
}),
|
|
876
|
+
tool('start_deployment', {
|
|
877
|
+
title: 'Start deployment',
|
|
878
|
+
description: 'SPENDS CREDITS. Starts a DRAFT or STOPPED deployment again with its existing settings (pays the boot time again). Requires confirm=true after the user agreed.',
|
|
879
|
+
annotations: SPENDS,
|
|
880
|
+
}, { deployment_id: z.string(), confirm: z.boolean().default(false) }, async (ctx, { deployment_id, confirm }) => {
|
|
881
|
+
const client = ctx.get();
|
|
882
|
+
const dep = (await client.api.deployments.get(deployment_id));
|
|
883
|
+
const usd = await usdPerHourForMarket(ctx, dep.market);
|
|
884
|
+
if (!confirm) {
|
|
885
|
+
return { ok: true, outcome: 'not_started', message: `NOT STARTED: ${dep.name} would run at ${usd ?? '?'} USD/h for up to ${dep.timeout} minutes per job (${dep.replicas} replica). Call again with confirm=true once the user agrees.`, deployment_id, status: dep.status, usd_per_hour: usd, timeout_minutes: dep.timeout, next_tool: 'start_deployment', next_args: { deployment_id, confirm: true } };
|
|
886
|
+
}
|
|
887
|
+
await dep.start();
|
|
888
|
+
return { ok: true, outcome: 'started', message: `Started ${deployment_id}. Poll wait_for_deployment.`, deployment_id, status: 'STARTING', usd_per_hour: usd, dashboard_url: dashboardUrl(deployment_id, ctx.network), next_tool: 'wait_for_deployment', next_args: { deployment_id, max_seconds: WAIT_DEFAULT_SECONDS } };
|
|
889
|
+
}),
|
|
890
|
+
tool('extend_deployment', {
|
|
891
|
+
title: 'Change deployment timeout',
|
|
892
|
+
description: 'Sets a new timeout in minutes for a deployment (minimum 60). Longer timeouts reserve more credits.',
|
|
893
|
+
annotations: SPENDS,
|
|
894
|
+
}, { deployment_id: z.string(), timeout_minutes: z.number().int().min(MIN_TIMEOUT_MINUTES) }, async (ctx, { deployment_id, timeout_minutes }) => {
|
|
895
|
+
const dep = (await ctx.get().api.deployments.get(deployment_id));
|
|
896
|
+
await dep.updateTimeout(timeout_minutes);
|
|
897
|
+
return { ok: true, message: `Timeout of ${deployment_id} set to ${timeout_minutes} minutes.`, deployment_id, timeout_minutes, next_tool: null };
|
|
898
|
+
}),
|
|
899
|
+
tool('get_job_result', {
|
|
900
|
+
title: 'Get job result and logs',
|
|
901
|
+
description: 'Logs and results of a deployment job (latest job by default).',
|
|
902
|
+
annotations: READ_ONLY,
|
|
903
|
+
}, { deployment_id: z.string(), job: z.string().optional().describe('Job address; defaults to the latest job.') }, async (ctx, { deployment_id, job }) => {
|
|
904
|
+
const dep = (await ctx.get().api.deployments.get(deployment_id));
|
|
905
|
+
let address = job;
|
|
906
|
+
if (!address) {
|
|
907
|
+
const jobs = (await dep.getJobs({ limit: 10 })).jobs;
|
|
908
|
+
if (!jobs.length)
|
|
909
|
+
return { ok: true, message: 'This deployment has not run any job yet.', deployment_id, next_tool: 'wait_for_deployment', next_args: { deployment_id } };
|
|
910
|
+
address = jobs[0].job;
|
|
911
|
+
}
|
|
912
|
+
const detail = await dep.getJob(address);
|
|
913
|
+
return {
|
|
914
|
+
ok: true,
|
|
915
|
+
message: `Job ${address} is ${normalizeJobState(detail.state)}.`,
|
|
916
|
+
deployment_id,
|
|
917
|
+
job: address,
|
|
918
|
+
state: normalizeJobState(detail.state),
|
|
919
|
+
host: detail.node,
|
|
920
|
+
explorer_url: explorerJobUrl(address, ctx.network),
|
|
921
|
+
logs: formatJobResult(detail.jobResult).map(stripAnsi),
|
|
922
|
+
next_tool: null,
|
|
923
|
+
};
|
|
924
|
+
}),
|
|
925
|
+
tool('get_deployment_events', {
|
|
926
|
+
title: 'Get deployment events',
|
|
927
|
+
description: 'Scheduler event log for a deployment (job listed, stopped, errors such as insufficient funds or bad timeout). Newest first.',
|
|
928
|
+
annotations: READ_ONLY,
|
|
929
|
+
}, { deployment_id: z.string(), limit: z.number().int().min(1).max(100).default(20) }, async (ctx, { deployment_id, limit }) => {
|
|
930
|
+
const dep = (await ctx.get().api.deployments.get(deployment_id));
|
|
931
|
+
const result = await dep.getEvents({ limit: pageSize(limit), sort_order: 'desc' });
|
|
932
|
+
const events = result.events.slice(0, limit);
|
|
933
|
+
return { ok: true, message: `${events.length} events.`, deployment_id, events: events.map((e) => ({ at: e.created_at, type: e.type, category: e.category, message: e.message })), next_tool: null };
|
|
934
|
+
}),
|
|
935
|
+
];
|
|
936
|
+
export async function templatesResource(ctx) {
|
|
937
|
+
const all = await ctx.templates();
|
|
938
|
+
return JSON.stringify(topLevelTemplates(all).map((t) => templateView(t, all)), null, 2);
|
|
939
|
+
}
|
|
940
|
+
export async function gpusResource(ctx) {
|
|
941
|
+
const catalog = await ctx.catalog();
|
|
942
|
+
return JSON.stringify(bucketsView(bucketGpus(catalog)), null, 2);
|
|
943
|
+
}
|
|
944
|
+
export const prompts = {
|
|
945
|
+
deploy_template(workload, gpu, timeout) {
|
|
946
|
+
return [
|
|
947
|
+
`Deploy "${workload}" on Nosana for me.`,
|
|
948
|
+
'',
|
|
949
|
+
'1. Call recommend_plan with this workload' + (timeout ? ` and timeout_minutes ${timeout}` : '') + '. If it returns needs_variant or needs_choice, ask me which one.',
|
|
950
|
+
`2. ${gpu ? `Use GPU "${gpu}" unless recommend_plan marks it unsupported.` : 'Prefer the recommended ready_now GPU. If nothing is ready, show me fits_but_queued and idle_with_risk and let me decide; never pick an unsupported GPU.'}`,
|
|
951
|
+
'3. If a similar deployment is already running, offer to reuse it instead.',
|
|
952
|
+
'4. Show me the estimated credits and USD per hour, then call create_deployment with confirm=true only after I say yes.',
|
|
953
|
+
'5. Poll wait_for_deployment (max_seconds 30) until the outcome is online or completed, telling me the phase each time.',
|
|
954
|
+
'6. When online, call get_endpoint_usage and give me the URL plus how to use it. Remind me to say "stop" when done so billing ends.',
|
|
955
|
+
].join('\n');
|
|
956
|
+
},
|
|
957
|
+
deploy_llm(model) {
|
|
958
|
+
return [
|
|
959
|
+
`Run ${model ? `the ${model}` : 'an open-weight'} LLM on a Nosana GPU and give me an OpenAI-compatible endpoint.`,
|
|
960
|
+
'',
|
|
961
|
+
`1. Call recommend_plan with workload "${model ?? 'qwen3-6-27b'}"${model ? '' : ' (or list_templates search "LLM" and ask me)'}.`,
|
|
962
|
+
'2. Pick the recommended ready_now GPU (cheapest that has enough VRAM and an idle host). Never send a 27B model to an 8 GB card.',
|
|
963
|
+
'3. Show me the cost per hour and the estimate, then create_deployment with confirm=true after I agree.',
|
|
964
|
+
'4. Poll wait_for_deployment until online. Model download takes a few minutes.',
|
|
965
|
+
'5. Call get_endpoint_usage and give me OPENAI_BASE_URL, the model id from /v1/models, and a curl example for /v1/chat/completions.',
|
|
966
|
+
'6. Remind me that it bills per hour until stop_deployment.',
|
|
967
|
+
].join('\n');
|
|
968
|
+
},
|
|
969
|
+
deploy_comfy(workflow) {
|
|
970
|
+
return [
|
|
971
|
+
`Deploy ${workflow ? `ComfyUI for ${workflow}` : 'ComfyUI'} on Nosana.`,
|
|
972
|
+
'',
|
|
973
|
+
`1. Call recommend_plan with workload "${workflow ?? 'comfyui'}". MiniMax H3 variants need a Blackwell GPU (5090 or PRO 6000) and 120 minutes; generic ComfyUI runs on small cards.`,
|
|
974
|
+
'2. Show me the recommended GPU and cost; create_deployment with confirm=true after I agree.',
|
|
975
|
+
'3. Poll wait_for_deployment until online (weights can take 5 to 15 minutes).',
|
|
976
|
+
'4. Give me the ComfyUI URL and the /prompt API usage from get_endpoint_usage.',
|
|
977
|
+
'5. Remind me to stop the deployment when I have downloaded my outputs.',
|
|
978
|
+
].join('\n');
|
|
979
|
+
},
|
|
980
|
+
deploy_minimax_h3(variant, gpu) {
|
|
981
|
+
return prompts.deploy_template(`minimax-h3/${variant ?? 'i2v-32gb'}`, gpu, '120');
|
|
982
|
+
},
|
|
983
|
+
stop_when_done() {
|
|
984
|
+
return [
|
|
985
|
+
'I am done with my Nosana GPU work.',
|
|
986
|
+
'',
|
|
987
|
+
'1. Call list_deployments with status "RUNNING,STARTING".',
|
|
988
|
+
'2. List them with name, GPU, USD per hour and how long they have been running.',
|
|
989
|
+
'3. Ask me which to stop (default: all), then call stop_deployment for each.',
|
|
990
|
+
'4. Confirm the final list_deployments shows nothing running, and call get_balance to show what is left.',
|
|
991
|
+
].join('\n');
|
|
992
|
+
},
|
|
993
|
+
};
|
|
994
|
+
//# sourceMappingURL=tools.js.map
|