badgr-cli 1.0.36 → 1.0.38
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/package.json +1 -1
- package/src/badgr.js +15 -1
- package/src/catalog.js +287 -0
- package/src/commands/down.js +10 -1
- package/src/commands/run.js +23 -0
- package/src/commands/serve.js +100 -10
- package/src/commands/template.js +119 -0
- package/tests/down.test.js +128 -0
- package/tests/serve-lifecycle.test.js +165 -0
- package/tests/template.test.js +549 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr template list
|
|
3
|
+
* badgr template info <name>
|
|
4
|
+
*
|
|
5
|
+
* Provider-neutral workload templates for common ML frameworks.
|
|
6
|
+
* Launching routes through `badgr serve template <name>` / `badgr run template <name>`.
|
|
7
|
+
*/
|
|
8
|
+
import { TEMPLATES, TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
|
|
9
|
+
import { serveCommand } from './serve.js';
|
|
10
|
+
import { runCommand } from './run.js';
|
|
11
|
+
|
|
12
|
+
// Re-export for tests
|
|
13
|
+
export { TEMPLATES };
|
|
14
|
+
|
|
15
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
function col(s, w) {
|
|
18
|
+
return String(s ?? '').padEnd(w).slice(0, w);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function printList(chalk) {
|
|
22
|
+
console.log(chalk.bold('\nAvailable templates\n'));
|
|
23
|
+
console.log(` ${'NAME'.padEnd(18)} ${'TYPE'.padEnd(8)} ${'GPU'.padEnd(10)} DESCRIPTION`);
|
|
24
|
+
console.log(` ${'─'.repeat(18)} ${'─'.repeat(8)} ${'─'.repeat(10)} ${'─'.repeat(44)}`);
|
|
25
|
+
for (const t of TEMPLATES) {
|
|
26
|
+
const type = t.type === 'endpoint' ? chalk.cyan(col(t.type, 8)) : chalk.yellow(col(t.type, 8));
|
|
27
|
+
console.log(` ${chalk.bold(col(t.name, 18))} ${type} ${col(t.gpu, 10)} ${t.description}`);
|
|
28
|
+
}
|
|
29
|
+
console.log();
|
|
30
|
+
console.log(chalk.dim(' badgr template info <name> Show full template details'));
|
|
31
|
+
console.log(chalk.dim(' badgr serve template <name> [flags] Launch an endpoint template'));
|
|
32
|
+
console.log(chalk.dim(' badgr run template <name> [flags] Launch a job template'));
|
|
33
|
+
console.log();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function printInfo(t, chalk) {
|
|
37
|
+
console.log(chalk.bold(`\n${t.title}\n`));
|
|
38
|
+
console.log(` ${chalk.bold('Name:')} ${t.name}`);
|
|
39
|
+
console.log(` ${chalk.bold('Description:')} ${t.description}`);
|
|
40
|
+
console.log(` ${chalk.bold('Type:')} ${t.type}`);
|
|
41
|
+
console.log(` ${chalk.bold('Image:')} ${t.image}`);
|
|
42
|
+
console.log(` ${chalk.bold('GPU:')} ${t.gpu} × ${t.gpu_count} (${t.min_vram_gb}+ GB VRAM)`);
|
|
43
|
+
if (t.port) console.log(` ${chalk.bold('Port:')} ${t.port}`);
|
|
44
|
+
if (t.health_path) console.log(` ${chalk.bold('Health:')} ${t.health_path}`);
|
|
45
|
+
if (t.env && Object.keys(t.env).length > 0) {
|
|
46
|
+
console.log(` ${chalk.bold('Env defaults:')}`);
|
|
47
|
+
for (const [k, v] of Object.entries(t.env)) {
|
|
48
|
+
console.log(` ${chalk.cyan(k)}=${chalk.dim(v)}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (t.command) {
|
|
52
|
+
console.log(` ${chalk.bold('Command:')} ${t.command.join(' ')}`);
|
|
53
|
+
}
|
|
54
|
+
if (t.notes?.length) {
|
|
55
|
+
console.log(`\n ${chalk.bold('Notes:')}`);
|
|
56
|
+
for (const n of t.notes) console.log(` ${chalk.dim(n)}`);
|
|
57
|
+
}
|
|
58
|
+
console.log();
|
|
59
|
+
const verb = t.type === 'job' ? 'run' : 'serve';
|
|
60
|
+
console.log(` ${chalk.bold('Launch:')}`);
|
|
61
|
+
console.log(chalk.dim(` badgr ${verb} template ${t.name} --max-cost <N>`));
|
|
62
|
+
console.log();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── Main command ───────────────────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
export async function templateCommand(config, args, chalk) {
|
|
68
|
+
const [sub, name, ...rest] = args;
|
|
69
|
+
|
|
70
|
+
if (!sub || sub === 'list' || sub === '--help' || sub === '-h') {
|
|
71
|
+
printList(chalk);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (sub === 'info') {
|
|
76
|
+
if (!name) {
|
|
77
|
+
console.error(chalk.red(' Usage: badgr template info <name>\n'));
|
|
78
|
+
printList(chalk);
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const t = TEMPLATE_MAP[name];
|
|
83
|
+
if (!t) {
|
|
84
|
+
console.error(chalk.red(`\n Unknown template: ${name}\n`));
|
|
85
|
+
console.error(chalk.dim(' Run `badgr template list` to see available templates.'));
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
printInfo(t, chalk);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// `badgr template run <name>` — kept as a convenience alias
|
|
94
|
+
if (sub === 'run') {
|
|
95
|
+
if (!name) {
|
|
96
|
+
console.error(chalk.red(' Usage: badgr template run <name> [--max-cost N] [--env K=V]\n'));
|
|
97
|
+
printList(chalk);
|
|
98
|
+
process.exitCode = 1;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
const t = TEMPLATE_MAP[name];
|
|
102
|
+
if (!t) {
|
|
103
|
+
console.error(chalk.red(`\n Unknown template: ${name}\n`));
|
|
104
|
+
console.error(chalk.dim(' Run `badgr template list` to see available templates.'));
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const overrides = parseTemplateOverrides(rest);
|
|
109
|
+
const flags = buildTemplateFlags(t, overrides);
|
|
110
|
+
console.log(chalk.dim(` Template: ${t.title} → badgr ${t.type === 'job' ? 'run' : 'serve'} ${flags.join(' ')}\n`));
|
|
111
|
+
return t.type === 'job'
|
|
112
|
+
? runCommand(config, flags, chalk)
|
|
113
|
+
: serveCommand(config, flags, chalk);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.error(chalk.red(`\n Unknown subcommand: badgr template ${sub}\n`));
|
|
117
|
+
console.error(chalk.dim(' Subcommands: list, info <name>'));
|
|
118
|
+
process.exitCode = 1;
|
|
119
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* badgr down — receipt display and runtime formatting tests
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
5
|
+
import { downCommand } from '../src/commands/down.js';
|
|
6
|
+
|
|
7
|
+
vi.mock('../src/api.js', () => ({
|
|
8
|
+
terminateDeployment: vi.fn(),
|
|
9
|
+
listDeployments: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/store.js', () => ({
|
|
13
|
+
findDeployment: vi.fn(() => null),
|
|
14
|
+
removeDeployment: vi.fn(),
|
|
15
|
+
addReceipt: vi.fn(),
|
|
16
|
+
generateReceiptId: vi.fn(() => 'rcpt-test-001'),
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('../src/config.js', () => ({
|
|
20
|
+
requireApiKey: vi.fn(),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import * as api from '../src/api.js';
|
|
24
|
+
import * as store from '../src/store.js';
|
|
25
|
+
|
|
26
|
+
const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
|
|
27
|
+
|
|
28
|
+
const chalk = {
|
|
29
|
+
bold: s => s,
|
|
30
|
+
dim: s => s,
|
|
31
|
+
red: s => s,
|
|
32
|
+
yellow: s => s,
|
|
33
|
+
green: s => s,
|
|
34
|
+
cyan: s => s,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function makeStoppedDep(runtimeSeconds, costPerHour = 1.00) {
|
|
38
|
+
const now = Date.now() / 1000;
|
|
39
|
+
return {
|
|
40
|
+
deployment_id: 'dep-test-001',
|
|
41
|
+
gpu_type: 'L40S',
|
|
42
|
+
cost_per_hour: costPerHour,
|
|
43
|
+
stopped_at: now,
|
|
44
|
+
started_at: now - runtimeSeconds,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
50
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
51
|
+
vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
|
|
52
|
+
vi.resetAllMocks();
|
|
53
|
+
store.findDeployment.mockReturnValue(null);
|
|
54
|
+
store.generateReceiptId.mockReturnValue('rcpt-test-001');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.restoreAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('runtime display formatting', () => {
|
|
62
|
+
it('shows minutes only for short runs (< 1h)', async () => {
|
|
63
|
+
api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
|
|
64
|
+
const lines = [];
|
|
65
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
66
|
+
|
|
67
|
+
await downCommand(config, ['dep-test-001'], chalk);
|
|
68
|
+
|
|
69
|
+
const runtimeLine = lines.find(l => l.includes('Runtime:'));
|
|
70
|
+
expect(runtimeLine).toMatch(/27m/);
|
|
71
|
+
expect(runtimeLine).not.toMatch(/\dh/);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('shows hours and minutes for runs >= 1h', async () => {
|
|
75
|
+
api.terminateDeployment.mockResolvedValue(makeStoppedDep(4091 * 60, 1.00));
|
|
76
|
+
const lines = [];
|
|
77
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
78
|
+
|
|
79
|
+
await downCommand(config, ['dep-test-001'], chalk);
|
|
80
|
+
|
|
81
|
+
const runtimeLine = lines.find(l => l.includes('Runtime:'));
|
|
82
|
+
expect(runtimeLine).toMatch(/2d 20h 11m/);
|
|
83
|
+
expect(runtimeLine).not.toMatch(/4091m/);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('shows days, hours, minutes for multi-day runs', async () => {
|
|
87
|
+
api.terminateDeployment.mockResolvedValue(makeStoppedDep(5722 * 60, 0.46));
|
|
88
|
+
const lines = [];
|
|
89
|
+
console.log.mockImplementation((...args) => lines.push(args.join(' ')));
|
|
90
|
+
|
|
91
|
+
await downCommand(config, ['dep-test-001'], chalk);
|
|
92
|
+
|
|
93
|
+
const runtimeLine = lines.find(l => l.includes('Runtime:'));
|
|
94
|
+
expect(runtimeLine).toMatch(/3d 23h 22m/);
|
|
95
|
+
expect(runtimeLine).not.toMatch(/5722m/);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('receipt is recorded', () => {
|
|
100
|
+
it('adds a receipt with correct fields on stop', async () => {
|
|
101
|
+
api.terminateDeployment.mockResolvedValue(makeStoppedDep(27 * 60, 0.34));
|
|
102
|
+
|
|
103
|
+
await downCommand(config, ['dep-test-001'], chalk);
|
|
104
|
+
|
|
105
|
+
expect(store.addReceipt).toHaveBeenCalledWith(expect.objectContaining({
|
|
106
|
+
receiptId: 'rcpt-test-001',
|
|
107
|
+
deploymentId: 'dep-test-001',
|
|
108
|
+
gpu: 'L40S',
|
|
109
|
+
status: 'terminated',
|
|
110
|
+
}));
|
|
111
|
+
const call = store.addReceipt.mock.calls[0][0];
|
|
112
|
+
expect(call.runtimeSeconds).toBeCloseTo(27 * 60, -1);
|
|
113
|
+
expect(call.finalCost).toBeGreaterThan(0);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('error handling', () => {
|
|
118
|
+
it('prints error and returns when terminateDeployment throws', async () => {
|
|
119
|
+
api.terminateDeployment.mockRejectedValue(new Error('network error'));
|
|
120
|
+
const errLines = [];
|
|
121
|
+
console.error.mockImplementation(msg => errLines.push(msg));
|
|
122
|
+
|
|
123
|
+
await downCommand(config, ['dep-test-001'], chalk);
|
|
124
|
+
|
|
125
|
+
expect(errLines.join('\n')).toMatch(/network error|Could not stop/i);
|
|
126
|
+
expect(store.addReceipt).not.toHaveBeenCalled();
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -498,3 +498,168 @@ describe('input validation for serve', () => {
|
|
|
498
498
|
expect(process.exitCode).toBe(1);
|
|
499
499
|
});
|
|
500
500
|
});
|
|
501
|
+
|
|
502
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
503
|
+
// 11. llama.cpp runtime (--runtime llama.cpp --gguf)
|
|
504
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
505
|
+
|
|
506
|
+
describe('--runtime llama.cpp', () => {
|
|
507
|
+
const HF_REPO = 'HauhauCS/Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive';
|
|
508
|
+
const HF_FILE = 'Qwen3.6-35B-A3B-Uncensored-HauhauCS-Aggressive-Q4_K_M.gguf';
|
|
509
|
+
|
|
510
|
+
it('errors when --hf-repo or --hf-file are missing', async () => {
|
|
511
|
+
await serveCommand(config, ['--runtime', 'llama.cpp', '--max-cost', '10'], chalk);
|
|
512
|
+
expect(process.exitCode).toBe(1);
|
|
513
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it('errors when only --hf-repo is given (missing --hf-file)', async () => {
|
|
517
|
+
await serveCommand(config, ['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--max-cost', '10'], chalk);
|
|
518
|
+
expect(process.exitCode).toBe(1);
|
|
519
|
+
expect(api.callApi).not.toHaveBeenCalled();
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
it('uses llama.cpp image with LLAMA_ARG_HF_REPO and LLAMA_ARG_HF_FILE env vars', async () => {
|
|
523
|
+
// --no-wait: only POST /serve called (1 mock needed)
|
|
524
|
+
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
525
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
526
|
+
|
|
527
|
+
const p = serveCommand(
|
|
528
|
+
config,
|
|
529
|
+
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10', '--no-wait'],
|
|
530
|
+
chalk,
|
|
531
|
+
);
|
|
532
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
533
|
+
await p;
|
|
534
|
+
|
|
535
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
536
|
+
expect(body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
|
|
537
|
+
expect(body.env.LLAMA_ARG_HF_REPO).toBe(HF_REPO);
|
|
538
|
+
expect(body.env.LLAMA_ARG_HF_FILE).toBe(HF_FILE);
|
|
539
|
+
expect(body.model).toBeUndefined();
|
|
540
|
+
expect(process.exitCode).toBeFalsy();
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
it('health-checks /health (not /models)', async () => {
|
|
544
|
+
api.callApi
|
|
545
|
+
.mockResolvedValueOnce(makeServeDep())
|
|
546
|
+
.mockResolvedValueOnce({ status: 'running' });
|
|
547
|
+
|
|
548
|
+
const fetchedUrls = [];
|
|
549
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
550
|
+
fetchedUrls.push(url);
|
|
551
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
const p = serveCommand(
|
|
555
|
+
config,
|
|
556
|
+
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10'],
|
|
557
|
+
chalk,
|
|
558
|
+
);
|
|
559
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
560
|
+
await p;
|
|
561
|
+
|
|
562
|
+
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
563
|
+
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
564
|
+
expect(process.exitCode).toBeFalsy();
|
|
565
|
+
});
|
|
566
|
+
|
|
567
|
+
it('records deployment and receipt as ready on success', async () => {
|
|
568
|
+
api.callApi
|
|
569
|
+
.mockResolvedValueOnce(makeServeDep())
|
|
570
|
+
.mockResolvedValueOnce({ status: 'running' });
|
|
571
|
+
|
|
572
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
573
|
+
|
|
574
|
+
const p = serveCommand(
|
|
575
|
+
config,
|
|
576
|
+
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE, '--max-cost', '10'],
|
|
577
|
+
chalk,
|
|
578
|
+
);
|
|
579
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
580
|
+
await p;
|
|
581
|
+
|
|
582
|
+
expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({
|
|
583
|
+
id: 'dep-serve-001',
|
|
584
|
+
}));
|
|
585
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
586
|
+
status: 'ready',
|
|
587
|
+
}));
|
|
588
|
+
expect(process.exitCode).toBeFalsy();
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
it('merges user --env with HF env vars', async () => {
|
|
592
|
+
// --no-wait: only POST /serve called (1 mock needed)
|
|
593
|
+
api.callApi.mockResolvedValueOnce(makeServeDep());
|
|
594
|
+
global.fetch = vi.fn().mockResolvedValue({ ok: true, status: 200 });
|
|
595
|
+
|
|
596
|
+
const p = serveCommand(
|
|
597
|
+
config,
|
|
598
|
+
['--runtime', 'llama.cpp', '--hf-repo', HF_REPO, '--hf-file', HF_FILE,
|
|
599
|
+
'--env', 'CTX_SIZE=8192', '--max-cost', '10', '--no-wait'],
|
|
600
|
+
chalk,
|
|
601
|
+
);
|
|
602
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
603
|
+
await p;
|
|
604
|
+
|
|
605
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
606
|
+
expect(body.env.LLAMA_ARG_HF_REPO).toBe(HF_REPO);
|
|
607
|
+
expect(body.env.LLAMA_ARG_HF_FILE).toBe(HF_FILE);
|
|
608
|
+
expect(body.env.CTX_SIZE).toBe('8192');
|
|
609
|
+
});
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
613
|
+
// 12. E2E smoke test — tiny HF GGUF (ggml-org/tiny-llamas / stories260K.gguf)
|
|
614
|
+
//
|
|
615
|
+
// Uses a real, publicly-available tiny GGUF (~1 MB) to verify the full
|
|
616
|
+
// request lifecycle: arg parsing → correct serve body → /health polling →
|
|
617
|
+
// receipt recorded as ready → OpenAI-compatible base URL returned.
|
|
618
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
describe('llama.cpp E2E smoke — tiny GGUF (ggml-org/tiny-llamas)', () => {
|
|
621
|
+
const TINY_REPO = 'ggml-org/tiny-llamas';
|
|
622
|
+
const TINY_FILE = 'stories260K.gguf';
|
|
623
|
+
|
|
624
|
+
it('full lifecycle: arg parse → serve body → /health → ready receipt', async () => {
|
|
625
|
+
api.callApi
|
|
626
|
+
.mockResolvedValueOnce(makeServeDep({ model: undefined })) // POST /serve
|
|
627
|
+
.mockResolvedValueOnce({ status: 'running' }); // pre-health dep check
|
|
628
|
+
|
|
629
|
+
const fetchedUrls = [];
|
|
630
|
+
global.fetch = vi.fn().mockImplementation((url) => {
|
|
631
|
+
fetchedUrls.push(url);
|
|
632
|
+
return Promise.resolve({ ok: true, status: 200 });
|
|
633
|
+
});
|
|
634
|
+
|
|
635
|
+
const p = serveCommand(
|
|
636
|
+
config,
|
|
637
|
+
['--runtime', 'llama.cpp',
|
|
638
|
+
'--hf-repo', TINY_REPO,
|
|
639
|
+
'--hf-file', TINY_FILE,
|
|
640
|
+
'--max-cost', '1'],
|
|
641
|
+
chalk,
|
|
642
|
+
);
|
|
643
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
644
|
+
await p;
|
|
645
|
+
|
|
646
|
+
// Correct image and env vars in the serve request
|
|
647
|
+
const body = api.callApi.mock.calls[0][1].body;
|
|
648
|
+
expect(body.image).toBe('michaelmanleyx/llama-cpp:server-cuda');
|
|
649
|
+
expect(body.env.LLAMA_ARG_HF_REPO).toBe(TINY_REPO);
|
|
650
|
+
expect(body.env.LLAMA_ARG_HF_FILE).toBe(TINY_FILE);
|
|
651
|
+
expect(body.model).toBeUndefined();
|
|
652
|
+
|
|
653
|
+
// Readiness check hits /health, not /models
|
|
654
|
+
expect(fetchedUrls.some(u => u.includes('/health'))).toBe(true);
|
|
655
|
+
expect(fetchedUrls.some(u => u.endsWith('/models'))).toBe(false);
|
|
656
|
+
|
|
657
|
+
// Receipt finalised as ready with endpoint URL
|
|
658
|
+
expect(store.updateReceipt).toHaveBeenCalledWith('rcpt-serve-001', expect.objectContaining({
|
|
659
|
+
status: 'ready',
|
|
660
|
+
endpointUrl: ENDPOINT_URL,
|
|
661
|
+
}));
|
|
662
|
+
|
|
663
|
+
expect(process.exitCode).toBeFalsy();
|
|
664
|
+
});
|
|
665
|
+
});
|