badgr-cli 1.0.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/HOW_IT_WORKS.md +245 -0
- package/README.md +147 -0
- package/badgr-cli-1.0.0.tgz +0 -0
- package/package.json +26 -0
- package/src/api.js +120 -0
- package/src/badgr.js +100 -0
- package/src/commands/deploy.js +47 -0
- package/src/commands/down.js +55 -0
- package/src/commands/login.js +20 -0
- package/src/commands/logs.js +49 -0
- package/src/commands/models.js +39 -0
- package/src/commands/receipts.js +82 -0
- package/src/commands/run.js +162 -0
- package/src/commands/serve.js +160 -0
- package/src/commands/shell.js +21 -0
- package/src/commands/status.js +97 -0
- package/src/commands/up.js +134 -0
- package/src/config.js +33 -0
- package/src/router.js +104 -0
- package/src/spec.js +92 -0
- package/src/store.js +88 -0
- package/tests/api.test.js +140 -0
- package/tests/commands.test.js +81 -0
- package/tests/config.test.js +73 -0
- package/tests/router.test.js +157 -0
- package/tests/spec.test.js +143 -0
- package/tests/store.test.js +126 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { parseSpec, validateSpec, specLines } from '../spec.js';
|
|
2
|
+
import { getRoutePlan } from '../router.js';
|
|
3
|
+
import { requireApiKey } from '../config.js';
|
|
4
|
+
import { generateDeploymentId, generateReceiptId, addDeployment, addReceipt } from '../store.js';
|
|
5
|
+
import { createDeployment } from '../api.js';
|
|
6
|
+
|
|
7
|
+
const OVERHEAD_PCT = 0.25;
|
|
8
|
+
|
|
9
|
+
function printRoutePlan(plan, chalk) {
|
|
10
|
+
const { lane1, lane2, cheapestRate, costWithOverhead, canonical } = plan;
|
|
11
|
+
|
|
12
|
+
console.log(chalk.bold(' Lane 1 — Own GPU Hosts'));
|
|
13
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
14
|
+
console.log(chalk.dim(` ${lane1.description}`));
|
|
15
|
+
console.log();
|
|
16
|
+
|
|
17
|
+
console.log(chalk.bold(' Lane 2 — Overflow Providers (cheapest-first)'));
|
|
18
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
19
|
+
if (lane2.length === 0) {
|
|
20
|
+
console.log(chalk.yellow(` No pricing data for ${canonical}`));
|
|
21
|
+
} else {
|
|
22
|
+
lane2.forEach((p, i) => {
|
|
23
|
+
const arrow = i === 0 ? chalk.green(' ← primary') : '';
|
|
24
|
+
console.log(
|
|
25
|
+
` ${String(i + 1)}. ${p.provider.padEnd(12)} ${canonical.padEnd(10)}` +
|
|
26
|
+
` $${p.ratePerHour.toFixed(2)}/hr` +
|
|
27
|
+
` reliability: ${Math.round(p.reliability * 100)}%${arrow}`
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
console.log();
|
|
31
|
+
console.log(` Estimated range: $${cheapestRate.toFixed(2)}–$${lane2[lane2.length - 1].ratePerHour.toFixed(2)}/hr`);
|
|
32
|
+
console.log(chalk.dim(` With 25% overhead (startup risk + badgr margin): ~$${costWithOverhead.toFixed(2)}/hr`));
|
|
33
|
+
}
|
|
34
|
+
console.log();
|
|
35
|
+
console.log(chalk.dim(' Remove --dry-run to provision.'));
|
|
36
|
+
console.log();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function upCommand(config, args, chalk) {
|
|
40
|
+
const spec = parseSpec(args);
|
|
41
|
+
const errors = validateSpec(spec);
|
|
42
|
+
|
|
43
|
+
if (errors.length > 0) {
|
|
44
|
+
errors.forEach(e => console.error(chalk.red(` ✗ ${e}`)));
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Dry-run: show route plan and exit ─────────────────────────────────────
|
|
49
|
+
if (spec.dryRun) {
|
|
50
|
+
console.log(chalk.bold('\n🔍 Dry Run — Route Plan\n'));
|
|
51
|
+
console.log(chalk.bold(' Spec'));
|
|
52
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
53
|
+
specLines(spec).forEach(l => console.log(` ${l}`));
|
|
54
|
+
console.log();
|
|
55
|
+
const plan = getRoutePlan(spec.gpu, spec.count);
|
|
56
|
+
printRoutePlan(plan, chalk);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ── Provision via backend API ─────────────────────────────────────────────
|
|
61
|
+
requireApiKey(config);
|
|
62
|
+
|
|
63
|
+
console.log(chalk.bold('\n🚀 Provisioning\n'));
|
|
64
|
+
console.log(chalk.bold(' Spec'));
|
|
65
|
+
console.log(` ${'─'.repeat(40)}`);
|
|
66
|
+
specLines(spec).forEach(l => console.log(` ${l}`));
|
|
67
|
+
console.log();
|
|
68
|
+
console.log(chalk.dim(' Routing through GPU.AI gateway (lane 1 → lane 2 → lane 3)...'));
|
|
69
|
+
|
|
70
|
+
let dep;
|
|
71
|
+
try {
|
|
72
|
+
dep = await createDeployment(config, {
|
|
73
|
+
workload_type: spec.type,
|
|
74
|
+
model: spec.model,
|
|
75
|
+
image: spec.image,
|
|
76
|
+
gpu_type: spec.gpu,
|
|
77
|
+
gpu_count: spec.count,
|
|
78
|
+
region: spec.region,
|
|
79
|
+
max_price_per_hour: spec.maxPrice ?? undefined,
|
|
80
|
+
name: spec.name ?? undefined,
|
|
81
|
+
});
|
|
82
|
+
} catch (err) {
|
|
83
|
+
console.error(chalk.red(`\n ✗ Provisioning failed: ${err.message}\n`));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Mirror to local store so gpu status/logs/receipts work offline
|
|
88
|
+
const localDep = {
|
|
89
|
+
id: dep.deployment_id,
|
|
90
|
+
name: dep.name,
|
|
91
|
+
type: dep.workload_type,
|
|
92
|
+
model: dep.model,
|
|
93
|
+
image: dep.image,
|
|
94
|
+
gpu: dep.gpu_type,
|
|
95
|
+
count: dep.gpu_count,
|
|
96
|
+
region: dep.region,
|
|
97
|
+
status: dep.status,
|
|
98
|
+
provider: dep.provider,
|
|
99
|
+
endpointUrl: dep.endpoint_url || config.baseUrl,
|
|
100
|
+
receiptId: dep.receipt_id,
|
|
101
|
+
createdAt: new Date(dep.created_at * 1000).toISOString(),
|
|
102
|
+
costPerHour: dep.cost_per_hour || 0,
|
|
103
|
+
};
|
|
104
|
+
addDeployment(localDep);
|
|
105
|
+
addReceipt({
|
|
106
|
+
receiptId: dep.receipt_id,
|
|
107
|
+
action: 'badgr up',
|
|
108
|
+
deploymentId: dep.deployment_id,
|
|
109
|
+
spec: { type: dep.workload_type, model: dep.model, gpu: dep.gpu_type, count: dep.gpu_count },
|
|
110
|
+
route: { lane: 2, provider: dep.provider, ratePerHour: dep.cost_per_hour },
|
|
111
|
+
retries: 0,
|
|
112
|
+
status: dep.status,
|
|
113
|
+
createdAt: new Date().toISOString(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
console.log(chalk.green('\n✓ Provisioned\n'));
|
|
117
|
+
console.log(` ${chalk.bold('Deployment:')} ${chalk.cyan(dep.deployment_id)}`);
|
|
118
|
+
console.log(` ${chalk.bold('Name:')} ${dep.name}`);
|
|
119
|
+
console.log(` ${chalk.bold('Type:')} ${dep.workload_type}`);
|
|
120
|
+
if (dep.model) console.log(` ${chalk.bold('Model:')} ${dep.model}`);
|
|
121
|
+
console.log(` ${chalk.bold('GPU:')} ${dep.gpu_type} × ${dep.gpu_count}`);
|
|
122
|
+
console.log(` ${chalk.bold('Provider:')} ${dep.provider}`);
|
|
123
|
+
console.log(` ${chalk.bold('Endpoint:')} ${chalk.cyan(dep.endpoint_url || config.baseUrl)}`);
|
|
124
|
+
if (dep.cost_per_hour > 0) console.log(` ${chalk.bold('Rate:')} $${dep.cost_per_hour.toFixed(2)}/hr`);
|
|
125
|
+
console.log(`\n ${chalk.bold('Receipt ID:')} ${chalk.dim(dep.receipt_id)}`);
|
|
126
|
+
|
|
127
|
+
if (dep.workload_type === 'endpoint') {
|
|
128
|
+
console.log(`\n ${chalk.bold('Use with OpenAI client:')}`);
|
|
129
|
+
const url = dep.endpoint_url || config.baseUrl;
|
|
130
|
+
console.log(chalk.dim(` client = OpenAI(api_key="sk-...", base_url="${url}")`));
|
|
131
|
+
console.log(chalk.dim(` # model: "${dep.model || 'meta-llama/Llama-3.1-8B-Instruct'}"`));
|
|
132
|
+
}
|
|
133
|
+
console.log();
|
|
134
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
2
|
+
import { homedir } from 'os';
|
|
3
|
+
import { join, dirname } from 'path';
|
|
4
|
+
|
|
5
|
+
export const CONFIG_DIR = join(homedir(), '.badgr');
|
|
6
|
+
export const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
|
|
7
|
+
|
|
8
|
+
export const DEFAULTS = {
|
|
9
|
+
baseUrl: 'https://api.badgr.ai/v1',
|
|
10
|
+
defaultModel: 'meta-llama/Llama-3.1-8B-Instruct',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function loadConfig(configFile = CONFIG_FILE) {
|
|
14
|
+
if (!existsSync(configFile)) return { ...DEFAULTS };
|
|
15
|
+
try {
|
|
16
|
+
return { ...DEFAULTS, ...JSON.parse(readFileSync(configFile, 'utf8')) };
|
|
17
|
+
} catch {
|
|
18
|
+
return { ...DEFAULTS };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function saveConfig(updates, configFile = CONFIG_FILE) {
|
|
23
|
+
const existing = loadConfig(configFile);
|
|
24
|
+
const merged = { ...existing, ...updates };
|
|
25
|
+
mkdirSync(dirname(configFile), { recursive: true });
|
|
26
|
+
writeFileSync(configFile, JSON.stringify(merged, null, 2));
|
|
27
|
+
return merged;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function requireApiKey(config) {
|
|
31
|
+
if (!config.apiKey) throw new Error('No API key configured. Run: badgr login');
|
|
32
|
+
return config.apiKey;
|
|
33
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GPU catalog and routing preview.
|
|
3
|
+
*
|
|
4
|
+
* Provider pricing mirrors the order in overflow_providers.py:
|
|
5
|
+
* Vast.ai → RunPod → TensorDock → SaladCloud.
|
|
6
|
+
* Actual routing happens server-side; this is the dry-run preview.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const GPU_CATALOG = [
|
|
10
|
+
{ id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
|
|
11
|
+
{ id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
|
|
12
|
+
{ id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
|
|
13
|
+
{ id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
|
|
14
|
+
{ id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
|
|
15
|
+
{ id: 'a100-80gb', canonical: 'A100', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
|
|
16
|
+
{ id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Provider-level pricing per canonical GPU type (ordered cheapest-first per provider).
|
|
20
|
+
// Mirrors overflow_providers.py search ordering: Vast → RunPod → TensorDock → Salad.
|
|
21
|
+
export const PROVIDER_CATALOG = {
|
|
22
|
+
RTX_3080: [
|
|
23
|
+
{ provider: 'vastai', ratePerHour: 0.28, reliability: 0.93 },
|
|
24
|
+
{ provider: 'runpod', ratePerHour: 0.35, reliability: 0.96 },
|
|
25
|
+
{ provider: 'tensordock', ratePerHour: 0.40, reliability: 0.90 },
|
|
26
|
+
],
|
|
27
|
+
RTX_4090: [
|
|
28
|
+
{ provider: 'vastai', ratePerHour: 0.65, reliability: 0.94 },
|
|
29
|
+
{ provider: 'runpod', ratePerHour: 0.72, reliability: 0.97 },
|
|
30
|
+
{ provider: 'tensordock', ratePerHour: 0.81, reliability: 0.91 },
|
|
31
|
+
{ provider: 'salad', ratePerHour: 0.89, reliability: 0.89 },
|
|
32
|
+
],
|
|
33
|
+
L40S: [
|
|
34
|
+
{ provider: 'vastai', ratePerHour: 1.10, reliability: 0.94 },
|
|
35
|
+
{ provider: 'runpod', ratePerHour: 1.25, reliability: 0.97 },
|
|
36
|
+
{ provider: 'salad', ratePerHour: 1.40, reliability: 0.88 },
|
|
37
|
+
],
|
|
38
|
+
A6000: [
|
|
39
|
+
{ provider: 'vastai', ratePerHour: 1.05, reliability: 0.93 },
|
|
40
|
+
{ provider: 'runpod', ratePerHour: 1.20, reliability: 0.97 },
|
|
41
|
+
{ provider: 'salad', ratePerHour: 1.35, reliability: 0.88 },
|
|
42
|
+
],
|
|
43
|
+
A100: [
|
|
44
|
+
{ provider: 'runpod', ratePerHour: 1.20, reliability: 0.98 },
|
|
45
|
+
{ provider: 'vastai', ratePerHour: 1.35, reliability: 0.95 },
|
|
46
|
+
{ provider: 'tensordock', ratePerHour: 1.50, reliability: 0.92 },
|
|
47
|
+
],
|
|
48
|
+
H100: [
|
|
49
|
+
{ provider: 'runpod', ratePerHour: 2.80, reliability: 0.99 },
|
|
50
|
+
{ provider: 'vastai', ratePerHour: 3.10, reliability: 0.96 },
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export function findById(id) {
|
|
55
|
+
return GPU_CATALOG.find(g => g.id === id) ?? null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function findByCanonical(canonical) {
|
|
59
|
+
return GPU_CATALOG.find(g => g.canonical === canonical) ?? null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function findCheapest(requirements = {}) {
|
|
63
|
+
const { minVramGb = 0, tag = null } = requirements;
|
|
64
|
+
const matches = GPU_CATALOG
|
|
65
|
+
.filter(g => g.vramGb >= minVramGb)
|
|
66
|
+
.filter(g => !tag || g.tags.includes(tag))
|
|
67
|
+
.sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
68
|
+
return matches[0] ?? null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function listAll() {
|
|
72
|
+
return [...GPU_CATALOG].sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Build the routing preview shown in --dry-run output.
|
|
77
|
+
* Mirrors the lane 1→2→3 logic in overflow_dispatch.py.
|
|
78
|
+
*/
|
|
79
|
+
export function getRoutePlan(canonical, count = 1) {
|
|
80
|
+
const providers = PROVIDER_CATALOG[canonical] ?? [];
|
|
81
|
+
const sorted = [...providers].sort((a, b) => a.ratePerHour - b.ratePerHour);
|
|
82
|
+
|
|
83
|
+
const cheapest = sorted[0];
|
|
84
|
+
const overhead = 0.25; // ~25% overhead: startup risk + failure buffer + badgr margin
|
|
85
|
+
const costWithOverhead = cheapest ? cheapest.ratePerHour * (1 + overhead) * count : null;
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
canonical,
|
|
89
|
+
gpu: findByCanonical(canonical),
|
|
90
|
+
lane1: { label: 'Own GPU hosts', description: 'checked at runtime against live worker pool' },
|
|
91
|
+
lane2: sorted.map((p, i) => ({
|
|
92
|
+
rank: i + 1,
|
|
93
|
+
provider: p.provider,
|
|
94
|
+
ratePerHour: p.ratePerHour * count,
|
|
95
|
+
reliability: p.reliability,
|
|
96
|
+
})),
|
|
97
|
+
cheapestRate: cheapest ? cheapest.ratePerHour * count : null,
|
|
98
|
+
costWithOverhead,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function estimateCost(ratePerHour, durationMinutes) {
|
|
103
|
+
return (ratePerHour / 60) * durationMinutes;
|
|
104
|
+
}
|
package/src/spec.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deployment spec: the single document that describes what `gpu up` should provision.
|
|
3
|
+
* Mirrors the concepts in overflow_dispatch.py (gpu_type, region, workload type).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const WORKLOAD_TYPES = ['endpoint', 'job'];
|
|
7
|
+
export const REGIONS = ['US', 'EU', 'AU'];
|
|
8
|
+
|
|
9
|
+
// Match canonical GPU IDs used in overflow_providers.py GPU_ALIASES
|
|
10
|
+
export const GPU_TYPE_MAP = {
|
|
11
|
+
'rtx-4090': 'RTX_4090', 'rtx4090': 'RTX_4090', '4090': 'RTX_4090',
|
|
12
|
+
'rtx-3090': 'RTX_3090', 'rtx3090': 'RTX_3090', '3090': 'RTX_3090',
|
|
13
|
+
'rtx-4080': 'RTX_4080', 'rtx4080': 'RTX_4080', '4080': 'RTX_4080',
|
|
14
|
+
'rtx-3080': 'RTX_3080', 'rtx3080': 'RTX_3080', '3080': 'RTX_3080',
|
|
15
|
+
'a6000': 'A6000',
|
|
16
|
+
'h100': 'H100',
|
|
17
|
+
'a100': 'A100', 'a100-80gb': 'A100', 'a100-40gb': 'A100',
|
|
18
|
+
'l40s': 'L40S',
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function normalizeGpuType(input) {
|
|
22
|
+
if (!input) return 'RTX_4090';
|
|
23
|
+
const lower = input.toLowerCase().replace(/[_\s]/g, '-');
|
|
24
|
+
return GPU_TYPE_MAP[lower] ?? input.toUpperCase().replace(/-/g, '_');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseFlags(args) {
|
|
28
|
+
const flags = {};
|
|
29
|
+
for (let i = 0; i < args.length; i++) {
|
|
30
|
+
const arg = args[i];
|
|
31
|
+
if (!arg.startsWith('--')) continue;
|
|
32
|
+
const key = arg.slice(2);
|
|
33
|
+
const next = args[i + 1];
|
|
34
|
+
if (next && !next.startsWith('--')) {
|
|
35
|
+
flags[key] = next;
|
|
36
|
+
i++;
|
|
37
|
+
} else {
|
|
38
|
+
flags[key] = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return flags;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseSpec(args) {
|
|
45
|
+
const flags = parseFlags(args);
|
|
46
|
+
const hasModel = Boolean(flags.model);
|
|
47
|
+
const hasImage = Boolean(flags.image);
|
|
48
|
+
|
|
49
|
+
// --endpoint / --job shorthands for --type endpoint|job
|
|
50
|
+
let type = flags.type
|
|
51
|
+
?? (flags.endpoint ? 'endpoint' : null)
|
|
52
|
+
?? (flags.job ? 'job' : null)
|
|
53
|
+
?? (hasImage ? 'job' : 'endpoint');
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
type,
|
|
57
|
+
model: flags.model ?? (type === 'endpoint' ? 'meta-llama/Llama-3.1-8B-Instruct' : null),
|
|
58
|
+
image: flags.image ?? (type === 'job' ? 'vllm/vllm-openai:latest' : null),
|
|
59
|
+
gpu: normalizeGpuType(flags.gpu),
|
|
60
|
+
count: Math.max(1, parseInt(flags.count ?? '1', 10)),
|
|
61
|
+
region: (flags.region ?? 'US').toUpperCase(),
|
|
62
|
+
maxPrice: flags['max-price'] ? parseFloat(flags['max-price']) : null,
|
|
63
|
+
name: flags.name ?? null,
|
|
64
|
+
dryRun: flags['dry-run'] === true || flags['dry-run'] === 'true',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function validateSpec(spec) {
|
|
69
|
+
const errors = [];
|
|
70
|
+
if (!WORKLOAD_TYPES.includes(spec.type)) {
|
|
71
|
+
errors.push(`Invalid --type "${spec.type}". Must be: ${WORKLOAD_TYPES.join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
if (isNaN(spec.count) || spec.count < 1 || spec.count > 8) {
|
|
74
|
+
errors.push('--count must be 1–8');
|
|
75
|
+
}
|
|
76
|
+
if (!REGIONS.includes(spec.region)) {
|
|
77
|
+
errors.push(`Invalid --region "${spec.region}". Must be: ${REGIONS.join(', ')}`);
|
|
78
|
+
}
|
|
79
|
+
return errors;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function specLines(spec) {
|
|
83
|
+
return [
|
|
84
|
+
`type: ${spec.type}`,
|
|
85
|
+
spec.model ? `model: ${spec.model}` : null,
|
|
86
|
+
spec.image ? `image: ${spec.image}` : null,
|
|
87
|
+
`gpu: ${spec.gpu} × ${spec.count}`,
|
|
88
|
+
`region: ${spec.region}`,
|
|
89
|
+
spec.maxPrice != null ? `max: $${spec.maxPrice.toFixed(2)}/GPU-hr` : null,
|
|
90
|
+
spec.name ? `name: ${spec.name}` : null,
|
|
91
|
+
].filter(Boolean);
|
|
92
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local deployment state — persisted to ~/.gpu/deployments.json.
|
|
3
|
+
*
|
|
4
|
+
* Tracks what `gpu up` has provisioned so `gpu down/status/logs/receipts`
|
|
5
|
+
* have something to work with before a backend deployments API exists.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { CONFIG_DIR } from './config.js';
|
|
10
|
+
import { randomUUID } from 'crypto';
|
|
11
|
+
|
|
12
|
+
export const STORE_FILE = join(CONFIG_DIR, 'deployments.json');
|
|
13
|
+
|
|
14
|
+
export function generateDeploymentId() {
|
|
15
|
+
return `dep-${randomUUID().replace(/-/g, '').slice(0, 8)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function generateReceiptId() {
|
|
19
|
+
return `rcpt-${randomUUID().replace(/-/g, '').slice(0, 10)}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function loadStore(storeFile = STORE_FILE) {
|
|
23
|
+
if (!existsSync(storeFile)) return { deployments: [], receipts: [] };
|
|
24
|
+
try {
|
|
25
|
+
const parsed = JSON.parse(readFileSync(storeFile, 'utf8'));
|
|
26
|
+
return {
|
|
27
|
+
deployments: parsed.deployments ?? [],
|
|
28
|
+
receipts: parsed.receipts ?? [],
|
|
29
|
+
};
|
|
30
|
+
} catch {
|
|
31
|
+
return { deployments: [], receipts: [] };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function saveStore(store, storeFile = STORE_FILE) {
|
|
36
|
+
mkdirSync(dirname(storeFile), { recursive: true });
|
|
37
|
+
writeFileSync(storeFile, JSON.stringify(store, null, 2));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function addDeployment(dep, storeFile = STORE_FILE) {
|
|
41
|
+
const store = loadStore(storeFile);
|
|
42
|
+
store.deployments.push(dep);
|
|
43
|
+
saveStore(store, storeFile);
|
|
44
|
+
return dep;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function updateDeployment(idOrName, updates, storeFile = STORE_FILE) {
|
|
48
|
+
const store = loadStore(storeFile);
|
|
49
|
+
const idx = store.deployments.findIndex(
|
|
50
|
+
d => d.id === idOrName || d.name === idOrName
|
|
51
|
+
);
|
|
52
|
+
if (idx === -1) return null;
|
|
53
|
+
store.deployments[idx] = { ...store.deployments[idx], ...updates };
|
|
54
|
+
saveStore(store, storeFile);
|
|
55
|
+
return store.deployments[idx];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function removeDeployment(idOrName, storeFile = STORE_FILE) {
|
|
59
|
+
const store = loadStore(storeFile);
|
|
60
|
+
const idx = store.deployments.findIndex(
|
|
61
|
+
d => d.id === idOrName || d.name === idOrName
|
|
62
|
+
);
|
|
63
|
+
if (idx === -1) return null;
|
|
64
|
+
const [removed] = store.deployments.splice(idx, 1);
|
|
65
|
+
saveStore(store, storeFile);
|
|
66
|
+
return removed;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function findDeployment(idOrName, storeFile = STORE_FILE) {
|
|
70
|
+
const { deployments } = loadStore(storeFile);
|
|
71
|
+
return deployments.find(d => d.id === idOrName || d.name === idOrName) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function listDeployments(storeFile = STORE_FILE) {
|
|
75
|
+
return loadStore(storeFile).deployments;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function addReceipt(receipt, storeFile = STORE_FILE) {
|
|
79
|
+
const store = loadStore(storeFile);
|
|
80
|
+
store.receipts.unshift(receipt); // newest first
|
|
81
|
+
if (store.receipts.length > 200) store.receipts.length = 200; // cap
|
|
82
|
+
saveStore(store, storeFile);
|
|
83
|
+
return receipt;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function listReceipts(limit = 20, storeFile = STORE_FILE) {
|
|
87
|
+
return loadStore(storeFile).receipts.slice(0, limit);
|
|
88
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { callApi, listModels, chatCompletion, submitJob, getJobStatus, listReceipts, getReceipt, runJob, serveModel } from '../src/api.js';
|
|
3
|
+
|
|
4
|
+
const mockConfig = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1', defaultModel: 'llama-3' };
|
|
5
|
+
|
|
6
|
+
function mockFetch(data, ok = true, status = 200) {
|
|
7
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
8
|
+
ok, status,
|
|
9
|
+
json: async () => data,
|
|
10
|
+
text: async () => JSON.stringify(data),
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('callApi', () => {
|
|
15
|
+
it('includes Authorization header', async () => {
|
|
16
|
+
mockFetch({ data: [] });
|
|
17
|
+
await callApi('/models', { apiKey: 'sk-abc', baseUrl: 'https://api.test/v1' });
|
|
18
|
+
const [, init] = global.fetch.mock.calls[0];
|
|
19
|
+
expect(init.headers.Authorization).toBe('Bearer sk-abc');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('throws on non-ok response with status code', async () => {
|
|
23
|
+
mockFetch('Unauthorized', false, 401);
|
|
24
|
+
await expect(callApi('/models', { apiKey: 'bad', baseUrl: 'https://api.test/v1' })).rejects.toThrow('401');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('sends JSON body for POST', async () => {
|
|
28
|
+
mockFetch({ id: 'j1' });
|
|
29
|
+
await callApi('/jobs', { method: 'POST', apiKey: 'sk-x', baseUrl: 'https://api.test/v1', body: { script: 'a.py' } });
|
|
30
|
+
expect(JSON.parse(global.fetch.mock.calls[0][1].body)).toEqual({ script: 'a.py' });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('omits body for GET', async () => {
|
|
34
|
+
mockFetch({});
|
|
35
|
+
await callApi('/models', { apiKey: 'x', baseUrl: 'https://api.test/v1' });
|
|
36
|
+
expect(global.fetch.mock.calls[0][1].body).toBeUndefined();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe('chatCompletion', () => {
|
|
41
|
+
it('POST /chat/completions with messages', async () => {
|
|
42
|
+
mockFetch({ choices: [{ message: { content: 'hi' } }] });
|
|
43
|
+
const msgs = [{ role: 'user', content: 'hello' }];
|
|
44
|
+
await chatCompletion(mockConfig, msgs);
|
|
45
|
+
const [url, init] = global.fetch.mock.calls[0];
|
|
46
|
+
expect(url).toContain('/chat/completions');
|
|
47
|
+
expect(JSON.parse(init.body).messages).toEqual(msgs);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('uses defaultModel when no model option', async () => {
|
|
51
|
+
mockFetch({ choices: [] });
|
|
52
|
+
await chatCompletion(mockConfig, []);
|
|
53
|
+
expect(JSON.parse(global.fetch.mock.calls[0][1].body).model).toBe('llama-3');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('uses provided model over default', async () => {
|
|
57
|
+
mockFetch({ choices: [] });
|
|
58
|
+
await chatCompletion(mockConfig, [], { model: 'gpt-4o' });
|
|
59
|
+
expect(JSON.parse(global.fetch.mock.calls[0][1].body).model).toBe('gpt-4o');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('listModels', () => {
|
|
64
|
+
it('calls GET /models', async () => {
|
|
65
|
+
mockFetch({ data: [{ id: 'llama-3' }] });
|
|
66
|
+
const result = await listModels(mockConfig);
|
|
67
|
+
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/models'), expect.any(Object));
|
|
68
|
+
expect(result.data[0].id).toBe('llama-3');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('submitJob / getJobStatus', () => {
|
|
73
|
+
it('POST /jobs with job body', async () => {
|
|
74
|
+
mockFetch({ jobId: 'abc-123' });
|
|
75
|
+
const result = await submitJob(mockConfig, { script: 'train.py', gpuType: 'rtx-4090' });
|
|
76
|
+
expect(result.jobId).toBe('abc-123');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('GET /jobs/:id', async () => {
|
|
80
|
+
mockFetch({ status: 'running' });
|
|
81
|
+
await getJobStatus(mockConfig, 'job-xyz');
|
|
82
|
+
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/jobs/job-xyz'), expect.any(Object));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('listReceipts', () => {
|
|
87
|
+
it('calls GET /receipts', async () => {
|
|
88
|
+
mockFetch({ object: 'list', data: [] });
|
|
89
|
+
await listReceipts(mockConfig);
|
|
90
|
+
const [url] = global.fetch.mock.calls[0];
|
|
91
|
+
expect(url).toContain('/receipts');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it('appends limit query param', async () => {
|
|
95
|
+
mockFetch({ object: 'list', data: [] });
|
|
96
|
+
await listReceipts(mockConfig, { limit: 50 });
|
|
97
|
+
const [url] = global.fetch.mock.calls[0];
|
|
98
|
+
expect(url).toContain('limit=50');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('appends status filter when provided', async () => {
|
|
102
|
+
mockFetch({ object: 'list', data: [] });
|
|
103
|
+
await listReceipts(mockConfig, { status: 'success' });
|
|
104
|
+
const [url] = global.fetch.mock.calls[0];
|
|
105
|
+
expect(url).toContain('status=success');
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe('getReceipt', () => {
|
|
110
|
+
it('calls GET /receipts/:id', async () => {
|
|
111
|
+
mockFetch({ request_id: 'req-123', status: 'success' });
|
|
112
|
+
const result = await getReceipt(mockConfig, 'req-123');
|
|
113
|
+
expect(global.fetch).toHaveBeenCalledWith(expect.stringContaining('/receipts/req-123'), expect.any(Object));
|
|
114
|
+
expect(result.request_id).toBe('req-123');
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe('runJob', () => {
|
|
119
|
+
it('POST /run with job spec', async () => {
|
|
120
|
+
mockFetch({ deployment_id: 'dep-abc', status: 'running', provider: 'runpod' });
|
|
121
|
+
const result = await runJob(mockConfig, { command: ['python', 'train.py'], gpu: 'A100' });
|
|
122
|
+
const [url, init] = global.fetch.mock.calls[0];
|
|
123
|
+
expect(url).toContain('/run');
|
|
124
|
+
expect(init.method).toBe('POST');
|
|
125
|
+
expect(JSON.parse(init.body).gpu).toBe('A100');
|
|
126
|
+
expect(result.deployment_id).toBe('dep-abc');
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('serveModel', () => {
|
|
131
|
+
it('POST /serve with model spec', async () => {
|
|
132
|
+
mockFetch({ deployment_id: 'dep-xyz', status: 'running', endpoint_url: 'https://api.badgr.ai/v1' });
|
|
133
|
+
const result = await serveModel(mockConfig, { model: 'meta-llama/Llama-3.1-8B-Instruct', gpu: 'L40S' });
|
|
134
|
+
const [url, init] = global.fetch.mock.calls[0];
|
|
135
|
+
expect(url).toContain('/serve');
|
|
136
|
+
expect(init.method).toBe('POST');
|
|
137
|
+
expect(JSON.parse(init.body).model).toBe('meta-llama/Llama-3.1-8B-Instruct');
|
|
138
|
+
expect(result.deployment_id).toBe('dep-xyz');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { parseRunArgs } from '../src/commands/run.js';
|
|
3
|
+
import { parseServeArgs } from '../src/commands/serve.js';
|
|
4
|
+
|
|
5
|
+
describe('parseRunArgs', () => {
|
|
6
|
+
it('parses a plain command', () => {
|
|
7
|
+
const { flags, positional } = parseRunArgs(['python', 'train.py']);
|
|
8
|
+
expect(positional).toEqual(['python', 'train.py']);
|
|
9
|
+
expect(flags.gpu).toBeUndefined();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('parses --gpu flag', () => {
|
|
13
|
+
const { flags, positional } = parseRunArgs(['python', 'train.py', '--gpu', 'A100']);
|
|
14
|
+
expect(positional).toEqual(['python', 'train.py']);
|
|
15
|
+
expect(flags.gpu).toBe('A100');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('parses --image flag', () => {
|
|
19
|
+
const { flags } = parseRunArgs(['--image', 'my/image:latest', '--gpu', 'L40S']);
|
|
20
|
+
expect(flags.image).toBe('my/image:latest');
|
|
21
|
+
expect(flags.gpu).toBe('L40S');
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('parses --max-price flag', () => {
|
|
25
|
+
const { flags } = parseRunArgs(['python', 'run.py', '--max-price', '2.5']);
|
|
26
|
+
expect(flags.maxPrice).toBe(2.5);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('parses --detach flag', () => {
|
|
30
|
+
const { flags, positional } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--detach']);
|
|
31
|
+
expect(positional).toEqual(['python', 'train.py']);
|
|
32
|
+
expect(flags.detach).toBe(true);
|
|
33
|
+
expect(flags.gpu).toBe('A100');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('detach defaults to falsy when not passed', () => {
|
|
37
|
+
const { flags } = parseRunArgs(['python', 'train.py']);
|
|
38
|
+
expect(flags.detach).toBeFalsy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('returns empty positional and flags for empty args', () => {
|
|
42
|
+
const { flags, positional } = parseRunArgs([]);
|
|
43
|
+
expect(positional).toEqual([]);
|
|
44
|
+
expect(flags.gpu).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('parseServeArgs', () => {
|
|
49
|
+
it('parses model positional arg', () => {
|
|
50
|
+
const { model, flags } = parseServeArgs(['meta-llama/Llama-3.1-8B-Instruct', '--gpu', 'L40S']);
|
|
51
|
+
expect(model).toBe('meta-llama/Llama-3.1-8B-Instruct');
|
|
52
|
+
expect(flags.gpu).toBe('L40S');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('parses --name flag', () => {
|
|
56
|
+
const { model, flags } = parseServeArgs(['mistralai/Mistral-7B-v0.1', '--name', 'prod-llm']);
|
|
57
|
+
expect(model).toBe('mistralai/Mistral-7B-v0.1');
|
|
58
|
+
expect(flags.name).toBe('prod-llm');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('parses --max-price flag', () => {
|
|
62
|
+
const { flags } = parseServeArgs(['my/model', '--max-price', '3.0']);
|
|
63
|
+
expect(flags.maxPrice).toBe(3.0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('parses --no-wait flag', () => {
|
|
67
|
+
const { model, flags } = parseServeArgs(['my/model', '--gpu', 'L40S', '--no-wait']);
|
|
68
|
+
expect(model).toBe('my/model');
|
|
69
|
+
expect(flags.noWait).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('noWait defaults to falsy when not passed', () => {
|
|
73
|
+
const { flags } = parseServeArgs(['my/model']);
|
|
74
|
+
expect(flags.noWait).toBeFalsy();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('returns null model when no positional arg', () => {
|
|
78
|
+
const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
|
|
79
|
+
expect(model).toBeNull();
|
|
80
|
+
});
|
|
81
|
+
});
|