badgr-cli 1.0.43 → 1.0.45

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.
@@ -0,0 +1,191 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import fs from 'fs';
3
+ import os from 'os';
4
+ import path from 'path';
5
+ import { detectWorkload, workloadTypeLabel } from '../src/detect.js';
6
+
7
+ let tmpRoot;
8
+
9
+ function makeProject(files) {
10
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'badgr-detect-'));
11
+ for (const [rel, content] of Object.entries(files)) {
12
+ const abs = path.join(dir, rel);
13
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
14
+ fs.writeFileSync(abs, content);
15
+ }
16
+ return dir;
17
+ }
18
+
19
+ beforeEach(() => { tmpRoot = []; });
20
+ afterEach(() => {
21
+ for (const dir of tmpRoot) {
22
+ fs.rmSync(dir, { recursive: true, force: true });
23
+ }
24
+ });
25
+
26
+ function project(files) {
27
+ const dir = makeProject(files);
28
+ tmpRoot.push(dir);
29
+ return dir;
30
+ }
31
+
32
+ describe('detectWorkload — missing / empty directory', () => {
33
+ it('reports an error without throwing for a missing directory', () => {
34
+ const report = detectWorkload('/nonexistent/badgr-detect-fixture');
35
+ expect(report.error).toBeTruthy();
36
+ expect(report.confidence).toBe('low');
37
+ });
38
+
39
+ it('is low confidence with no command for an empty directory', () => {
40
+ const dir = project({});
41
+ const report = detectWorkload(dir);
42
+ expect(report.command).toBeNull();
43
+ expect(report.confidence).toBe('low');
44
+ });
45
+ });
46
+
47
+ describe('detectWorkload — plain Python CUDA script', () => {
48
+ it('finds the entry point and requirements, classifies as custom', () => {
49
+ const dir = project({
50
+ 'train.py': `
51
+ import torch
52
+ model = torch.nn.Linear(10, 10).cuda()
53
+ for step in range(100):
54
+ optimizer.step()
55
+ `,
56
+ 'requirements.txt': 'torch\nnumpy\n',
57
+ 'output/.gitkeep': '',
58
+ });
59
+ const report = detectWorkload(dir);
60
+ expect(report.command).toBe('python train.py');
61
+ expect(report.requirements).toContain('requirements.txt');
62
+ expect(report.outputs).toContain('output/');
63
+ expect(report.workloadType).toBe('training');
64
+ expect(['medium', 'high']).toContain(report.confidence);
65
+ });
66
+ });
67
+
68
+ describe('detectWorkload — vLLM endpoint', () => {
69
+ it('classifies as endpoint and detects the port', () => {
70
+ const dir = project({
71
+ 'server.py': `
72
+ from vllm import LLM
73
+ import uvicorn
74
+ app = None
75
+
76
+ if __name__ == "__main__":
77
+ uvicorn.run(app, port=8000)
78
+ `,
79
+ 'requirements.txt': 'vllm\nfastapi\nuvicorn\n',
80
+ });
81
+ const report = detectWorkload(dir);
82
+ expect(report.command).toBe('python server.py');
83
+ expect(report.workloadType).toBe('endpoint');
84
+ expect(report.ports).toContain(8000);
85
+ expect(report.healthPath).toBe('/health');
86
+ });
87
+ });
88
+
89
+ describe('detectWorkload — LoRA training repo', () => {
90
+ it('classifies as training and estimates high VRAM for a large model', () => {
91
+ const dir = project({
92
+ 'train.py': `
93
+ from peft import LoraConfig
94
+ base_model = "meta-llama/Llama-3.1-70B-Instruct"
95
+ trainer = Trainer(model=model)
96
+ trainer.train()
97
+ `,
98
+ 'config.yaml': 'base_model: meta-llama/Llama-3.1-70B-Instruct\nlora_r: 16\n',
99
+ 'requirements.txt': 'peft\ntransformers\naccelerate\n',
100
+ 'checkpoints/.gitkeep': '',
101
+ });
102
+ const report = detectWorkload(dir);
103
+ expect(report.workloadType).toBe('training');
104
+ expect(report.checkpoints).toContain('checkpoints/');
105
+ expect(report.models.some(m => m.includes('Llama-3.1-70B'))).toBe(true);
106
+ expect(report.vram).toBe('80+ GB');
107
+ });
108
+ });
109
+
110
+ describe('detectWorkload — ComfyUI-style project', () => {
111
+ it('classifies as image_gen from a workflow json without special-casing ComfyUI by name', () => {
112
+ const dir = project({
113
+ 'workflow_api.json': JSON.stringify({ '1': { class_type: 'CheckpointLoaderSimple' } }),
114
+ 'requirements.txt': 'torch\npillow\n',
115
+ });
116
+ const report = detectWorkload(dir);
117
+ expect(report.workloadType).toBe('image_gen');
118
+ expect(workloadTypeLabel(report.workloadType)).toMatch(/image generation/);
119
+ });
120
+ });
121
+
122
+ describe('detectWorkload — custom Docker image', () => {
123
+ it('detects the Dockerfile and exposed port instead of guessing a base image', () => {
124
+ const dir = project({
125
+ Dockerfile: `
126
+ FROM python:3.11-slim
127
+ COPY . /app
128
+ EXPOSE 7860
129
+ CMD ["python", "app.py"]
130
+ `,
131
+ 'app.py': `
132
+ import gradio as gr
133
+ demo = gr.Interface()
134
+ demo.launch(server_port=7860)
135
+ `,
136
+ });
137
+ const report = detectWorkload(dir);
138
+ expect(report.dockerfile).toBe('Dockerfile');
139
+ expect(report.image).toBeNull();
140
+ expect(report.ports).toContain(7860);
141
+ expect(report.workloadType).toBe('endpoint');
142
+ });
143
+ });
144
+
145
+ describe('detectWorkload — RunPod-style serverless handler job', () => {
146
+ it('classifies a handler-based job as an endpoint without naming any provider', () => {
147
+ const dir = project({
148
+ 'rp_handler.py': `
149
+ import runpod
150
+
151
+ def handler(event):
152
+ return {"output": "ok"}
153
+
154
+ runpod.serverless.start({"handler": handler})
155
+ `,
156
+ 'requirements.txt': 'runpod\n',
157
+ });
158
+ const report = detectWorkload(dir);
159
+ expect(report.command).toBe('python rp_handler.py');
160
+ expect(report.workloadType).toBe('endpoint');
161
+ // No provider name should leak into any user-visible field.
162
+ const serialized = JSON.stringify(report).toLowerCase();
163
+ expect(serialized).not.toContain('runpod.com');
164
+ });
165
+ });
166
+
167
+ describe('detectWorkload — batch image generation over an input directory', () => {
168
+ it('classifies as batch when the script iterates an input directory', () => {
169
+ const dir = project({
170
+ 'generate.py': `
171
+ import os
172
+ for fname in os.listdir("input"):
173
+ process(fname)
174
+ `,
175
+ 'input/.gitkeep': '',
176
+ 'output/.gitkeep': '',
177
+ });
178
+ const report = detectWorkload(dir);
179
+ expect(report.workloadType).toBe('batch');
180
+ expect(report.inputs).toContain('input/');
181
+ expect(report.outputs).toContain('output/');
182
+ });
183
+ });
184
+
185
+ describe('workloadTypeLabel', () => {
186
+ it('never returns a provider-specific label', () => {
187
+ for (const t of ['endpoint', 'training', 'image_gen', 'batch', 'custom', 'unknown']) {
188
+ expect(workloadTypeLabel(t)).not.toMatch(/runpod|vast|hyperstack/i);
189
+ }
190
+ });
191
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { pollJobUntilTerminal, renderJobClosingBlock } from '../src/progress.js';
3
+
4
+ const chalk = new Proxy({}, { get: () => (s) => s });
5
+ const config = { apiKey: 'k', baseUrl: 'https://api.test' };
6
+
7
+ function silenceStdout() {
8
+ return vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
9
+ }
10
+
11
+ describe('pollJobUntilTerminal', () => {
12
+ it('returns immediately once the job reaches a terminal status', async () => {
13
+ const writeSpy = silenceStdout();
14
+ const detail = { job_id: 'job_1', status: 'completed', stage_label: 'Complete', health: 'completed' };
15
+ const callApi = vi.fn().mockResolvedValue(detail);
16
+
17
+ const result = await pollJobUntilTerminal(callApi, config, 'job_1', { chalk, maxMs: 60_000, pollMs: 1 });
18
+
19
+ expect(result.outcome).toBe('completed');
20
+ expect(result.detail).toBe(detail);
21
+ writeSpy.mockRestore();
22
+ });
23
+
24
+ it('surfaces progress fields (stage/current/total) while polling', async () => {
25
+ const writeSpy = silenceStdout();
26
+ const callApi = vi.fn()
27
+ .mockResolvedValueOnce({ job_id: 'job_2', status: 'running', stage_label: 'Generating images', progress_current: 3, progress_total: 10, progress_unit: 'images', health: 'progressing' })
28
+ .mockResolvedValueOnce({ job_id: 'job_2', status: 'completed', stage_label: 'Complete', health: 'completed' });
29
+
30
+ const result = await pollJobUntilTerminal(callApi, config, 'job_2', { chalk, maxMs: 60_000, pollMs: 1 });
31
+
32
+ expect(result.outcome).toBe('completed');
33
+ const output = writeSpy.mock.calls.map(c => c[0]).join('');
34
+ expect(output).toContain('3/10 images');
35
+ writeSpy.mockRestore();
36
+ });
37
+
38
+ it('does not swallow repeated poll failures silently — gives up and reports polling_failed', async () => {
39
+ const writeSpy = silenceStdout();
40
+ const callApi = vi.fn().mockRejectedValue(new Error('network down'));
41
+
42
+ const result = await pollJobUntilTerminal(callApi, config, 'job_3', { chalk, maxMs: 60_000, pollMs: 1 });
43
+
44
+ expect(result.outcome).toBe('polling_failed');
45
+ // Must have warned the user at least once instead of looping silently.
46
+ const output = writeSpy.mock.calls.map(c => c[0]).join('');
47
+ expect(output).toMatch(/Lost contact/i);
48
+ writeSpy.mockRestore();
49
+ });
50
+
51
+ it('announces a retry-on-a-different-route once, with teardown/billing status', async () => {
52
+ const writeSpy = silenceStdout();
53
+ const callApi = vi.fn()
54
+ .mockResolvedValueOnce({
55
+ job_id: 'job_retry', status: 'running', stage: 'retrying_different_route',
56
+ stage_label: 'Retrying on a different route', health: 'progressing',
57
+ progress_message: 'Retrying on a different route... attempt 2/2',
58
+ teardown_status: 'ok',
59
+ })
60
+ .mockResolvedValueOnce({
61
+ job_id: 'job_retry', status: 'completed', stage_label: 'Complete', health: 'completed',
62
+ });
63
+
64
+ const result = await pollJobUntilTerminal(callApi, config, 'job_retry', { chalk, maxMs: 60_000, pollMs: 1 });
65
+
66
+ expect(result.outcome).toBe('completed');
67
+ const output = writeSpy.mock.calls.map(c => c[0]).join('');
68
+ expect(output).toContain('Retrying on a different route... attempt 2/2');
69
+ expect(output).toContain('Previous attempt teardown: succeeded');
70
+ expect(output).toContain('Billing: stopped');
71
+ writeSpy.mockRestore();
72
+ });
73
+
74
+ it('recovers from transient poll failures once the API responds again', async () => {
75
+ const writeSpy = silenceStdout();
76
+ const callApi = vi.fn()
77
+ .mockRejectedValueOnce(new Error('timeout'))
78
+ .mockRejectedValueOnce(new Error('timeout'))
79
+ .mockResolvedValueOnce({ job_id: 'job_4', status: 'completed', stage_label: 'Complete', health: 'completed' });
80
+
81
+ const result = await pollJobUntilTerminal(callApi, config, 'job_4', { chalk, maxMs: 60_000, pollMs: 1 });
82
+
83
+ expect(result.outcome).toBe('completed');
84
+ writeSpy.mockRestore();
85
+ });
86
+ });
87
+
88
+ describe('renderJobClosingBlock', () => {
89
+ it('shows teardown/billing/logs/receipt for a completed job', () => {
90
+ const block = renderJobClosingBlock(chalk, {
91
+ job_id: 'job_5', status: 'completed', elapsed_seconds: 252,
92
+ charged_usd: 0.08, teardown_status: 'ok', billing_status: 'stopped',
93
+ }, 'rcpt-abc');
94
+
95
+ expect(block).toContain('Complete');
96
+ expect(block).toContain('Teardown: succeeded');
97
+ expect(block).toContain('Billing: stopped');
98
+ expect(block).toContain('badgr logs job_5');
99
+ expect(block).toContain('badgr receipts rcpt-abc');
100
+ });
101
+
102
+ it('shows failure class, stage, and next action for a failed job', () => {
103
+ const block = renderJobClosingBlock(chalk, {
104
+ job_id: 'job_6', status: 'failed', elapsed_seconds: 1112,
105
+ error: { code: 'comfy_health_failed', message: 'ComfyUI did not start within 10 minutes.' },
106
+ failure_class: 'health_check_failed', stage: 'comfy_waiting_for_health',
107
+ teardown_status: 'ok', billing_status: 'stopped',
108
+ next_action: 'Run `badgr logs <job_id>` or retry with a longer max-runtime.',
109
+ });
110
+
111
+ expect(block).toContain('Failed');
112
+ expect(block).toContain('Class: health_check_failed');
113
+ expect(block).toContain('Stage: comfy_waiting_for_health');
114
+ expect(block).toContain('Next:');
115
+ });
116
+
117
+ it('labels a runtime/spend cap failure as "Stopped — limit reached", not a crash', () => {
118
+ const block = renderJobClosingBlock(chalk, {
119
+ job_id: 'job_7', status: 'failed', elapsed_seconds: 545,
120
+ error: { code: 'max_runtime_reached', message: 'Job reached max runtime and was stopped. Billing ended.' },
121
+ failure_class: 'runtime_cap_reached',
122
+ teardown_status: 'ok', billing_status: 'stopped',
123
+ });
124
+
125
+ expect(block).toContain('Stopped — limit reached');
126
+ });
127
+
128
+ it('shows cancelled block distinctly from failed', () => {
129
+ const block = renderJobClosingBlock(chalk, {
130
+ job_id: 'job_8', status: 'canceled', elapsed_seconds: 164,
131
+ teardown_status: 'ok', billing_status: 'stopped',
132
+ });
133
+
134
+ expect(block).toContain('Cancelled by user');
135
+ });
136
+ });
@@ -131,6 +131,13 @@ describe('BLESSED_COMFY_WORKFLOWS catalog', () => {
131
131
  expect(wf.gpu_type).toBe('RTX_4090');
132
132
  expect(wf.output_type).toBe('images');
133
133
  });
134
+
135
+ it('has flux-basic with gpu_type and output_type', () => {
136
+ const wf = BLESSED_COMFY_WORKFLOWS['flux-basic'];
137
+ expect(wf).toBeDefined();
138
+ expect(wf.gpu_type).toBe('RTX_4090');
139
+ expect(wf.output_type).toBe('images');
140
+ });
134
141
  });
135
142
 
136
143
  // ── parseServeArgs alias recognition ─────────────────────────────────────────
@@ -15,7 +15,7 @@
15
15
  * - Receipt explains state → status, exitCode, failureType, runtimeSeconds, finalCost
16
16
  */
17
17
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
18
- import { runCommand } from '../src/commands/run.js';
18
+ import { runCommand, parseRunArgs } from '../src/commands/run.js';
19
19
 
20
20
  // ── Module mocks ──────────────────────────────────────────────────────────────
21
21
 
@@ -222,6 +222,26 @@ describe('container fails to start', () => {
222
222
 
223
223
  expect(process.exitCode).toBe(1);
224
224
  });
225
+
226
+ it('prints the backend failure_class/next_action using the same vocabulary as the job API', async () => {
227
+ api.callApi
228
+ .mockResolvedValueOnce(makeDep({ status: 'provisioning' }))
229
+ .mockResolvedValueOnce({
230
+ status: 'failed',
231
+ failure_class: 'container_start_failed',
232
+ next_action: 'Check the image and command, then retry.',
233
+ });
234
+ const logs = [];
235
+ const origError = console.error;
236
+ console.error = (...args) => { logs.push(args.join(' ')); origError(...args); };
237
+ const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
238
+ await vi.advanceTimersByTimeAsync(4000);
239
+ await p;
240
+ console.error = origError;
241
+
242
+ expect(logs.some(l => l.includes('Class: container_start_failed'))).toBe(true);
243
+ expect(logs.some(l => l.includes('Next: Check the image and command, then retry.'))).toBe(true);
244
+ });
225
245
  });
226
246
 
227
247
  // ─────────────────────────────────────────────────────────────────────────────
@@ -543,3 +563,93 @@ describe('--dry-run', () => {
543
563
  expect(api.callApi).not.toHaveBeenCalled();
544
564
  });
545
565
  });
566
+
567
+ // ─────────────────────────────────────────────────────────────────────────────
568
+ // 13. Progress-loss handling — --output / --checkpoint / --retry-safe / --resume-cmd
569
+ // ─────────────────────────────────────────────────────────────────────────────
570
+
571
+ describe('--output / --checkpoint / --retry-safe / --resume-cmd', () => {
572
+ it('parseRunArgs parses all four flags', () => {
573
+ const { flags } = parseRunArgs([
574
+ '--output', './outputs', '--checkpoint', './checkpoints', '--retry-safe',
575
+ '--resume-cmd', 'python train.py --resume ./checkpoints/latest',
576
+ '--max-cost', '5', '--', 'python', 'train.py',
577
+ ]);
578
+ expect(flags.output).toBe('./outputs');
579
+ expect(flags.checkpoint).toBe('./checkpoints');
580
+ expect(flags.retrySafe).toBe(true);
581
+ expect(flags.resumeCmd).toBe('python train.py --resume ./checkpoints/latest');
582
+ });
583
+
584
+ it('wires --output/--checkpoint/--retry-safe into the job env as BADGR_* vars', async () => {
585
+ api.callApi
586
+ .mockResolvedValueOnce(makeDep())
587
+ .mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
588
+ .mockResolvedValueOnce({ logs: [] });
589
+ const p = runCommand(config, [
590
+ '--output', './outputs', '--checkpoint', './checkpoints', '--retry-safe',
591
+ '--max-cost', '5', '--', 'python', 'train.py',
592
+ ], chalk);
593
+ await vi.advanceTimersByTimeAsync(5000);
594
+ await p;
595
+
596
+ const postRunBody = api.callApi.mock.calls[0][1].body;
597
+ expect(postRunBody.env).toEqual(expect.objectContaining({
598
+ BADGR_OUTPUT_DIR: './outputs',
599
+ BADGR_CHECKPOINT_DIR: './checkpoints',
600
+ BADGR_RETRY_SAFE: '1',
601
+ }));
602
+ });
603
+
604
+ it('explicit --env overrides the BADGR_* convention var for the same key', async () => {
605
+ api.callApi
606
+ .mockResolvedValueOnce(makeDep())
607
+ .mockResolvedValueOnce({ status: 'completed', exit_code: 0 })
608
+ .mockResolvedValueOnce({ logs: [] });
609
+ const p = runCommand(config, [
610
+ '--output', './outputs', '--env', 'BADGR_OUTPUT_DIR=./custom-outputs',
611
+ '--max-cost', '5', '--', 'python', 'train.py',
612
+ ], chalk);
613
+ await vi.advanceTimersByTimeAsync(5000);
614
+ await p;
615
+
616
+ const postRunBody = api.callApi.mock.calls[0][1].body;
617
+ expect(postRunBody.env.BADGR_OUTPUT_DIR).toBe('./custom-outputs');
618
+ });
619
+
620
+ it('prints the --resume-cmd hint when the job fails', async () => {
621
+ api.callApi
622
+ .mockResolvedValueOnce(makeDep())
623
+ .mockResolvedValueOnce({ status: 'failed', exit_code: 1 })
624
+ .mockResolvedValueOnce({ logs: [] });
625
+ const logs = [];
626
+ const captureChalk = { bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s };
627
+ const origLog = console.log;
628
+ console.log = (...args) => { logs.push(args.join(' ')); origLog(...args); };
629
+ const p = runCommand(config, [
630
+ '--resume-cmd', 'python train.py --resume ./checkpoints/latest',
631
+ '--max-cost', '5', '--', 'python', 'train.py',
632
+ ], captureChalk);
633
+ await vi.advanceTimersByTimeAsync(5000);
634
+ await p;
635
+ console.log = origLog;
636
+
637
+ expect(logs.some(l => l.includes('python train.py --resume ./checkpoints/latest'))).toBe(true);
638
+ });
639
+
640
+ it('does not print a Resume line when --resume-cmd was not given', async () => {
641
+ api.callApi
642
+ .mockResolvedValueOnce(makeDep())
643
+ .mockResolvedValueOnce({ status: 'failed', exit_code: 1 })
644
+ .mockResolvedValueOnce({ logs: [] });
645
+ const logs = [];
646
+ const origLog = console.log;
647
+ console.log = (...args) => { logs.push(args.join(' ')); origLog(...args); };
648
+ const p = runCommand(config, ['python', 'train.py', '--max-cost', '5'], chalk);
649
+ await vi.advanceTimersByTimeAsync(5000);
650
+ await p;
651
+ console.log = origLog;
652
+
653
+ expect(logs.some(l => l.includes('Resume:'))).toBe(false);
654
+ });
655
+ });
@@ -0,0 +1,189 @@
1
+ /**
2
+ * `badgr serve openwebui` — a chat UI that connects to a model endpoint.
3
+ *
4
+ * Reuses a running vLLM endpoint for the requested model if one exists, or
5
+ * launches one first, then wires OPENAI_API_BASE_URL/OPENAI_API_KEY to it —
6
+ * via the same serveCommand/template machinery `badgr serve template <name>`
7
+ * already uses. No new provisioning path.
8
+ */
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
10
+ import { serveCommand } from '../src/commands/serve.js';
11
+
12
+ vi.mock('../src/api.js', () => ({
13
+ callApi: vi.fn(),
14
+ terminateDeployment: vi.fn().mockResolvedValue({}),
15
+ listDeployments: vi.fn().mockResolvedValue({ deployments: [], count: 0 }),
16
+ }));
17
+
18
+ vi.mock('../src/store.js', () => ({
19
+ addDeployment: vi.fn(),
20
+ addReceipt: vi.fn(),
21
+ updateReceipt: vi.fn(),
22
+ generateReceiptId: vi.fn(() => 'rcpt-app-001'),
23
+ generateDeploymentId: vi.fn(() => 'dep-app-001'),
24
+ listDeployments: vi.fn(() => []),
25
+ listReceipts: vi.fn(() => []),
26
+ findDeployment: vi.fn(() => null),
27
+ updateDeployment: vi.fn(),
28
+ removeDeployment: vi.fn(),
29
+ }));
30
+
31
+ import * as api from '../src/api.js';
32
+ import * as store from '../src/store.js';
33
+
34
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
35
+ const chalk = {
36
+ bold: s => s, dim: s => s, red: s => s, yellow: s => s, green: s => s, cyan: s => s,
37
+ };
38
+
39
+ function makeServeDep(overrides = {}) {
40
+ return {
41
+ deployment_id: 'dep-app-001',
42
+ status: 'running',
43
+ gpu_type: 'RTX_4090',
44
+ gpu_count: 1,
45
+ cost_per_hour: 0.50,
46
+ provider: 'runpod',
47
+ receipt_id: 'rcpt-app-001',
48
+ tier: '1',
49
+ endpoint_url: 'https://dep-app-001.aibadgr.com/v1',
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ // Queues one POST /serve → status → ready cycle onto api.callApi.
55
+ function queueServeCycle(depOverrides = {}) {
56
+ api.callApi
57
+ .mockResolvedValueOnce(makeServeDep(depOverrides))
58
+ .mockResolvedValueOnce({ status: 'running' })
59
+ .mockResolvedValueOnce({ status: 'running', endpoint_ready: true });
60
+ }
61
+
62
+ beforeEach(() => {
63
+ vi.useFakeTimers();
64
+ process.exitCode = undefined;
65
+ vi.spyOn(console, 'log').mockImplementation(() => {});
66
+ vi.spyOn(console, 'error').mockImplementation(() => {});
67
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
68
+ vi.clearAllMocks();
69
+ store.generateReceiptId.mockReturnValue('rcpt-app-001');
70
+ api.terminateDeployment.mockResolvedValue({});
71
+ api.listDeployments.mockResolvedValue({ deployments: [], count: 0 });
72
+ });
73
+
74
+ describe('badgr serve openwebui — reuses a running vLLM endpoint', () => {
75
+ it('connects to an existing endpoint instead of launching a second one', async () => {
76
+ api.listDeployments.mockResolvedValueOnce({
77
+ deployments: [{
78
+ status: 'running',
79
+ workload_type: 'endpoint',
80
+ model: 'Qwen/Qwen2.5-7B-Instruct',
81
+ endpoint_url: 'https://dep-existing.aibadgr.com/v1',
82
+ }],
83
+ count: 1,
84
+ });
85
+ queueServeCycle(); // only one cycle — openwebui itself
86
+
87
+ const p = serveCommand(config, ['openwebui', '--model', 'qwen-7b', '--max-cost', '5'], chalk);
88
+ await vi.advanceTimersByTimeAsync(5000);
89
+ await p;
90
+
91
+ expect(api.callApi.mock.calls.length).toBe(3); // no second /serve for vLLM
92
+ const [, opts] = api.callApi.mock.calls[0];
93
+ expect(opts.body.image).toBe('ghcr.io/open-webui/open-webui:main');
94
+ expect(opts.body.env.OPENAI_API_BASE_URL).toBe('https://dep-existing.aibadgr.com/v1');
95
+ expect(process.exitCode).toBeFalsy();
96
+ });
97
+ });
98
+
99
+ describe('badgr serve openwebui — launches vLLM first when none is running', () => {
100
+ it('serves vLLM (warning about the separate budget), then Open WebUI wired to the new endpoint', async () => {
101
+ // listDeployments is called 4 times: pre-launch discovery (none) →
102
+ // vLLM's own duplicate check (none) → post-launch discovery (found) →
103
+ // Open WebUI's own duplicate check (none — no image field to match).
104
+ api.listDeployments
105
+ .mockResolvedValueOnce({ deployments: [], count: 0 })
106
+ .mockResolvedValueOnce({ deployments: [], count: 0 })
107
+ .mockResolvedValueOnce({
108
+ deployments: [{
109
+ deployment_id: 'dep-vllm-new',
110
+ status: 'running',
111
+ workload_type: 'endpoint',
112
+ model: 'Qwen/Qwen2.5-7B-Instruct',
113
+ endpoint_url: 'https://dep-new.aibadgr.com/v1',
114
+ }],
115
+ count: 1,
116
+ });
117
+ queueServeCycle({ model: 'Qwen/Qwen2.5-7B-Instruct' }); // vLLM launch
118
+ queueServeCycle(); // Open WebUI launch
119
+
120
+ const p = serveCommand(config, ['openwebui', '--model', 'qwen-7b', '--max-cost', '5'], chalk);
121
+ await vi.advanceTimersByTimeAsync(10_000);
122
+ await p;
123
+
124
+ expect(api.callApi.mock.calls.length).toBe(6);
125
+ const [, vllmOpts] = api.callApi.mock.calls[0];
126
+ expect(vllmOpts.body.model).toBe('Qwen/Qwen2.5-7B-Instruct');
127
+ const [, webuiOpts] = api.callApi.mock.calls[3];
128
+ expect(webuiOpts.body.image).toBe('ghcr.io/open-webui/open-webui:main');
129
+ expect(webuiOpts.body.env.OPENAI_API_BASE_URL).toBe('https://dep-new.aibadgr.com/v1');
130
+ expect(process.exitCode).toBeFalsy();
131
+
132
+ // --max-cost applies to both the auto-launched vLLM and Open WebUI
133
+ // separately — the CLI must say so rather than double-spend silently.
134
+ const logged = console.log.mock.calls.flat().join('\n');
135
+ expect(logged).toContain('own --max-cost $5 cap');
136
+ expect(logged).toContain('Total possible spend across both deployments: ~$10.00');
137
+ });
138
+
139
+ it('prints a badgr down hint for the auto-launched vLLM deployment if Open WebUI then fails', async () => {
140
+ api.listDeployments
141
+ .mockResolvedValueOnce({ deployments: [], count: 0 }) // pre-launch discovery
142
+ .mockResolvedValueOnce({ deployments: [], count: 0 }) // vLLM's own dup-check
143
+ .mockResolvedValueOnce({ // post-launch discovery — vLLM is up
144
+ deployments: [{
145
+ deployment_id: 'dep-vllm-999',
146
+ status: 'running',
147
+ workload_type: 'endpoint',
148
+ model: 'Qwen/Qwen2.5-7B-Instruct',
149
+ endpoint_url: 'https://dep-vllm-999.aibadgr.com/v1',
150
+ }],
151
+ count: 1,
152
+ })
153
+ .mockResolvedValueOnce({ deployments: [], count: 0 }); // Open WebUI's own dup-check
154
+
155
+ queueServeCycle({ model: 'Qwen/Qwen2.5-7B-Instruct' }); // vLLM launch succeeds
156
+ // Open WebUI's own POST /serve "succeeds" but hands back no endpoint URL —
157
+ // serveCommand's own no-endpoint-url guard fires and sets exitCode = 1.
158
+ api.callApi.mockResolvedValueOnce(makeServeDep({ endpoint_url: undefined }));
159
+
160
+ const p = serveCommand(config, ['openwebui', '--model', 'qwen-7b', '--max-cost', '5'], chalk);
161
+ await vi.advanceTimersByTimeAsync(10_000);
162
+ await p;
163
+
164
+ expect(process.exitCode).toBe(1);
165
+ const logged = console.error.mock.calls.flat().join('\n');
166
+ expect(logged).toContain('badgr down dep-vllm-999');
167
+ });
168
+ });
169
+
170
+ describe('badgr serve openwebui --connect', () => {
171
+ it('skips vLLM discovery/launch entirely and wires the given URL', async () => {
172
+ queueServeCycle();
173
+ const p = serveCommand(
174
+ config,
175
+ ['openwebui', '--connect', 'https://my-server.example.com/v1', '--max-cost', '5'],
176
+ chalk,
177
+ );
178
+ await vi.advanceTimersByTimeAsync(5000);
179
+ await p;
180
+
181
+ // Only the one serve cycle ran (Open WebUI itself) — no vLLM discovery/launch.
182
+ expect(api.callApi.mock.calls.length).toBe(3);
183
+ const [, opts] = api.callApi.mock.calls[0];
184
+ expect(opts.body.env.OPENAI_API_BASE_URL).toBe('https://my-server.example.com/v1');
185
+
186
+ const logged = console.log.mock.calls.flat().join('\n');
187
+ expect(logged).not.toContain('needs a model endpoint behind it');
188
+ });
189
+ });