badgr-cli 1.0.45 → 1.0.46

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "badgr-cli",
3
- "version": "1.0.45",
3
+ "version": "1.0.46",
4
4
  "description": "Badgr — run or serve GPU workloads from one command",
5
5
  "type": "module",
6
6
  "bin": {
package/src/api.js CHANGED
@@ -168,6 +168,24 @@ export async function terminateDeployment(config, deploymentId) {
168
168
  throw lastErr;
169
169
  }
170
170
 
171
+ export function restartDeployment(config, deploymentId) {
172
+ return callApi(`/deployments/${deploymentId}/restart`, {
173
+ method: 'POST',
174
+ apiKey: config.apiKey,
175
+ baseUrl: config.baseUrl,
176
+ timeoutMs: 30_000,
177
+ });
178
+ }
179
+
180
+ export function heartbeatDeployment(config, deploymentId) {
181
+ return callApi(`/deployments/${deploymentId}/heartbeat`, {
182
+ method: 'POST',
183
+ apiKey: config.apiKey,
184
+ baseUrl: config.baseUrl,
185
+ timeoutMs: 10_000,
186
+ });
187
+ }
188
+
171
189
  export function getDeploymentLogs(config, deploymentId) {
172
190
  return callApi(`/deployments/${deploymentId}/logs`, {
173
191
  apiKey: config.apiKey,
package/src/badgr.js CHANGED
@@ -21,6 +21,8 @@ import { templateCommand } from './commands/template.js';
21
21
  import { workloadCommand } from './commands/workload.js';
22
22
  import { workspaceCommand } from './commands/workspace.js';
23
23
  import { detectCommand } from './commands/detect.js';
24
+ import { restartCommand } from './commands/restart.js';
25
+ import { heartbeatCommand } from './commands/heartbeat.js';
24
26
 
25
27
  const HELP = `
26
28
  ${chalk.bold('badgr')} — run or serve GPU workloads from one command
@@ -34,6 +36,8 @@ ${chalk.bold('COMMANDS')}
34
36
  ${chalk.cyan('badgr status')} Show what's running and what's billing
35
37
  ${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
36
38
  ${chalk.cyan('badgr down <id>')} Stop a deployment and end billing
39
+ ${chalk.cyan('badgr restart <id>')} Relaunch an endpoint with the same config and API key
40
+ ${chalk.cyan('badgr heartbeat <id>')} Reset an endpoint's idle-timeout clock
37
41
  ${chalk.cyan('badgr receipts')} Show cost history
38
42
  ${chalk.cyan('badgr test')} Run an end-to-end test (provision → run → teardown)
39
43
  ${chalk.cyan('badgr capacity')} Check what GPU capacity is available right now
@@ -158,6 +162,8 @@ async function main() {
158
162
  case 'status': return statusCommand(config, rest, chalk);
159
163
  case 'logs': return logsCommand(config, rest, chalk);
160
164
  case 'down': return downCommand(config, rest, chalk);
165
+ case 'restart': return restartCommand(config, rest, chalk);
166
+ case 'heartbeat': return heartbeatCommand(config, rest, chalk);
161
167
  case 'receipts': return receiptsCommand(config, rest, chalk);
162
168
  case 'models': return modelsCommand(config, chalk);
163
169
  case 'capacity': return capacityCommand(config, rest, chalk);
@@ -0,0 +1,38 @@
1
+ import { requireApiKey } from '../config.js';
2
+ import { findDeployment } from '../store.js';
3
+ import { heartbeatDeployment } from '../api.js';
4
+
5
+ /**
6
+ * badgr heartbeat <deployment-id|name>
7
+ *
8
+ * Resets an endpoint's idle-timeout clock. Badgr doesn't proxy inference
9
+ * traffic, so if you set --idle-timeout on `badgr serve`, call this on
10
+ * each real request (or wire it into your own client) — otherwise the
11
+ * endpoint is torn down once idle_timeout_minutes elapses, even if it's
12
+ * still reachable.
13
+ */
14
+ export async function heartbeatCommand(config, args, chalk) {
15
+ const idOrName = args.find(a => !a.startsWith('--'));
16
+
17
+ if (!idOrName) {
18
+ console.error(chalk.red('Usage: badgr heartbeat <deployment-id|name>'));
19
+ console.error(chalk.dim(' Resets the idle-timeout clock for an endpoint launched with --idle-timeout.'));
20
+ return;
21
+ }
22
+
23
+ requireApiKey(config);
24
+
25
+ const localDep = findDeployment(idOrName);
26
+ const deploymentId = localDep?.id ?? idOrName;
27
+
28
+ try {
29
+ const result = await heartbeatDeployment(config, deploymentId);
30
+ console.log(chalk.green(` ✓ Heartbeat recorded for ${deploymentId}`));
31
+ if (result.last_activity_at) {
32
+ console.log(chalk.dim(` last_activity_at: ${new Date(result.last_activity_at * 1000).toISOString()}`));
33
+ }
34
+ } catch (err) {
35
+ console.error(chalk.red(` ✗ Could not send heartbeat: ${err.message}`));
36
+ process.exitCode = 1;
37
+ }
38
+ }
@@ -0,0 +1,74 @@
1
+ import { requireApiKey } from '../config.js';
2
+ import { findDeployment, removeDeployment, addDeployment, addReceipt, generateReceiptId } from '../store.js';
3
+ import { restartDeployment } from '../api.js';
4
+
5
+ /**
6
+ * badgr restart <deployment-id|name>
7
+ *
8
+ * Tears down the current pod and relaunches an endpoint with the same
9
+ * config (GPU, model, price/cost/runtime caps, endpoint API key). Returns
10
+ * a *new* deployment_id and endpoint_url — the old pod's IP is gone.
11
+ */
12
+ export async function restartCommand(config, args, chalk) {
13
+ const idOrName = args.find(a => !a.startsWith('--'));
14
+
15
+ if (!idOrName) {
16
+ console.error(chalk.red('Usage: badgr restart <deployment-id|name>'));
17
+ return;
18
+ }
19
+
20
+ requireApiKey(config);
21
+
22
+ const localDep = findDeployment(idOrName);
23
+ const deploymentId = localDep?.id ?? idOrName;
24
+
25
+ process.stdout.write(chalk.dim(` Restarting ${deploymentId}...`));
26
+
27
+ let dep;
28
+ try {
29
+ dep = await restartDeployment(config, deploymentId);
30
+ } catch (err) {
31
+ process.stdout.write('\n');
32
+ console.error(chalk.red(`\n ✗ Could not restart deployment: ${err.message}\n`));
33
+ process.exitCode = 1;
34
+ return;
35
+ }
36
+
37
+ process.stdout.write('\n');
38
+
39
+ removeDeployment(idOrName);
40
+ addDeployment({
41
+ id: dep.deployment_id,
42
+ name: dep.name,
43
+ type: dep.workload_type,
44
+ model: dep.model,
45
+ gpu: dep.gpu_type,
46
+ count: dep.gpu_count,
47
+ status: dep.status,
48
+ endpointUrl: dep.endpoint_url || dep.openai_base_url,
49
+ receiptId: dep.receipt_id,
50
+ createdAt: new Date().toISOString(),
51
+ costPerHour: dep.cost_per_hour || 0,
52
+ providerRoute: dep.provider ?? null,
53
+ tier: dep.tier ?? null,
54
+ });
55
+
56
+ const rcptId = dep.receipt_id || generateReceiptId();
57
+ addReceipt({
58
+ receiptId: rcptId,
59
+ action: 'badgr restart',
60
+ deploymentId: dep.deployment_id,
61
+ gpu: dep.gpu_type,
62
+ status: dep.status,
63
+ createdAt: new Date().toISOString(),
64
+ });
65
+
66
+ const endpointUrl = dep.endpoint_url || dep.openai_base_url;
67
+ console.log(chalk.green('\n ✓ Restarted\n'));
68
+ console.log(` ${chalk.bold('New deployment:')} ${chalk.cyan(dep.deployment_id)}`);
69
+ if (endpointUrl) console.log(` ${chalk.bold('New base URL:')} ${chalk.cyan(endpointUrl)}`);
70
+ console.log(chalk.dim(' Old endpoint URL is gone — update any client pointed at it.'));
71
+ console.log(chalk.dim(' Your endpoint API key is unchanged — no need to re-issue it.'));
72
+ console.log(` ${chalk.bold('Status:')} badgr status ${dep.deployment_id}`);
73
+ console.log();
74
+ }
@@ -32,6 +32,7 @@ export function parseServeArgs(args) {
32
32
  if (args[i] === '--name') { flags.name = args[++i]; i++; continue; }
33
33
  if (args[i] === '--no-wait') { flags.noWait = true; i++; continue; }
34
34
  if (args[i] === '--max-cost') { flags.maxCost = parseFloat(args[++i]); i++; continue; }
35
+ if (args[i] === '--idle-timeout') { flags.idleTimeout = parseInt(args[++i], 10); i++; continue; }
35
36
  if (args[i] === '--health-path') { flags.healthPath = args[++i]; i++; continue; }
36
37
  if (args[i] === '--check-nodes') { flags.checkNodes = args[++i]; i++; continue; }
37
38
  // All three aliases map to noMarketplaceFallback
@@ -208,7 +209,7 @@ async function validateComfyNodes(baseUrl, nodeList, chalk) {
208
209
  // Known badgr serve flags — used to detect broken shell line continuation.
209
210
  const _KNOWN_SERVE_FLAGS = new Set([
210
211
  '--gpu', '--image', '--task', '--count', '--region', '--tier', '--max-price',
211
- '--name', '--no-wait', '--max-cost', '--health-path', '--check-nodes',
212
+ '--name', '--no-wait', '--max-cost', '--idle-timeout', '--health-path', '--check-nodes',
212
213
  '--no-fallback', '--strict-capacity', '--no-expanded-search', '--env',
213
214
  '--persistent', '--yes', '-y', '--runtime', '--hf-repo', '--hf-file',
214
215
  ]);
@@ -543,6 +544,7 @@ export async function serveCommand(config, args, chalk) {
543
544
  tier: tierOverride || effectiveTier,
544
545
  ...(Object.keys(effectiveEnv).length > 0 ? { env: effectiveEnv } : {}),
545
546
  ...(flags.maxCost ? { max_cost_usd: flags.maxCost } : {}),
547
+ ...(flags.idleTimeout ? { idle_timeout_minutes: flags.idleTimeout } : {}),
546
548
  ...(flags.healthPath ? { health_path: flags.healthPath } : {}),
547
549
  };
548
550
  }
@@ -746,22 +748,34 @@ export async function serveCommand(config, args, chalk) {
746
748
  else if (dep.model || effectiveModel) console.log(` ${chalk.bold('Model:')} ${dep.model || effectiveModel}`);
747
749
  if (customImage) console.log(` ${chalk.bold('Image:')} ${customImage}`);
748
750
  if (flags.maxCost) console.log(` ${chalk.bold('Max cost:')} $${flags.maxCost.toFixed(2)}`);
751
+ if (flags.idleTimeout) console.log(` ${chalk.bold('Idle timeout:')} ${flags.idleTimeout}m (auto-stops if idle — see Heartbeat below)`);
749
752
  console.log(` ${chalk.bold('Logs:')} badgr logs ${dep.deployment_id}`);
753
+ console.log(` ${chalk.bold('Restart:')} badgr restart ${dep.deployment_id}`);
750
754
  console.log(` ${chalk.bold('Stop billing:')} ${chalk.cyan(`badgr down ${dep.deployment_id}`)}`);
751
755
  console.log(` ${chalk.bold('Receipt:')} badgr receipts ${rcptId}`);
752
756
  console.log();
753
757
  console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
754
758
 
755
759
  if (endpointReady && !customImage) {
756
- const keySnip = config.apiKey?.slice(0, 4) || 'sk-...';
760
+ // dep.endpoint_api_key is a per-endpoint key generated for this deployment
761
+ // (vLLM model serves only) — shown exactly once, here. Falls back to the
762
+ // account-wide key (truncated) for serves that don't get one yet
763
+ // (custom images, managed transcribe/image tasks).
764
+ const hasEndpointKey = Boolean(dep.endpoint_api_key);
765
+ const authKey = hasEndpointKey ? dep.endpoint_api_key : `${config.apiKey?.slice(0, 4) || 'sk-...'}...`;
757
766
  const sdkModel = isLlamaCpp ? 'default' : (dep.model || effectiveModel);
767
+
768
+ if (hasEndpointKey) {
769
+ console.log(` ${chalk.bold('API key:')} ${chalk.yellow(dep.endpoint_api_key)}`);
770
+ console.log(chalk.dim(' Shown once — copy it now. This key is scoped to this endpoint only.'));
771
+ }
758
772
  console.log(` ${chalk.bold('Test with curl:')}`);
759
773
  console.log(chalk.dim(` curl ${endpointUrl}/chat/completions \\`));
760
- console.log(chalk.dim(` -H "Authorization: Bearer ${keySnip}..." -H "Content-Type: application/json" \\`));
774
+ console.log(chalk.dim(` -H "Authorization: Bearer ${authKey}" -H "Content-Type: application/json" \\`));
761
775
  console.log(chalk.dim(` -d '{"model":"${sdkModel}","messages":[{"role":"user","content":"Hello"}]}'`));
762
776
  console.log(` ${chalk.bold('Use with OpenAI SDK:')}`);
763
777
  console.log(chalk.dim(` from openai import OpenAI`));
764
- console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${keySnip}...")`));
778
+ console.log(chalk.dim(` client = OpenAI(base_url="${endpointUrl}", api_key="${authKey}")`));
765
779
  if (flags.task === 'transcribe') {
766
780
  console.log(chalk.dim(` with open("audio.mp3", "rb") as f:`));
767
781
  console.log(chalk.dim(` t = client.audio.transcriptions.create(model="${sdkModel}", file=f, response_format="text")`));
@@ -774,5 +788,13 @@ export async function serveCommand(config, args, chalk) {
774
788
  console.log(chalk.dim(` resp = client.chat.completions.create(model="${sdkModel}", messages=[{"role": "user", "content": "Hello"}])`));
775
789
  }
776
790
  console.log();
791
+ if (flags.idleTimeout) {
792
+ console.log(` ${chalk.bold('Heartbeat (required for --idle-timeout):')}`);
793
+ console.log(chalk.dim(` badgr heartbeat ${dep.deployment_id}`));
794
+ console.log(chalk.dim(' Badgr does not proxy your inference traffic, so call this on each real'));
795
+ console.log(chalk.dim(` request (or wire it into your client) — otherwise this endpoint auto-stops`));
796
+ console.log(chalk.dim(` after ${flags.idleTimeout}m even if it's still reachable.`));
797
+ console.log();
798
+ }
777
799
  }
778
800
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * badgr heartbeat — resets an endpoint's idle-timeout clock
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import { heartbeatCommand } from '../src/commands/heartbeat.js';
6
+
7
+ vi.mock('../src/api.js', () => ({
8
+ heartbeatDeployment: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('../src/store.js', () => ({
12
+ findDeployment: vi.fn(() => null),
13
+ }));
14
+
15
+ vi.mock('../src/config.js', () => ({
16
+ requireApiKey: vi.fn(),
17
+ }));
18
+
19
+ import * as api from '../src/api.js';
20
+ import * as store from '../src/store.js';
21
+
22
+ const config = { apiKey: 'sk-test', baseUrl: 'https://api.test/v1' };
23
+
24
+ const chalk = {
25
+ bold: s => s,
26
+ dim: s => s,
27
+ red: s => s,
28
+ yellow: s => s,
29
+ green: s => s,
30
+ cyan: s => s,
31
+ };
32
+
33
+ beforeEach(() => {
34
+ process.exitCode = undefined;
35
+ vi.spyOn(console, 'log').mockImplementation(() => {});
36
+ vi.spyOn(console, 'error').mockImplementation(() => {});
37
+ vi.resetAllMocks();
38
+ store.findDeployment.mockReturnValue(null);
39
+ });
40
+
41
+ afterEach(() => {
42
+ vi.restoreAllMocks();
43
+ process.exitCode = undefined;
44
+ });
45
+
46
+ it('requires a deployment id', async () => {
47
+ await heartbeatCommand(config, [], chalk);
48
+ expect(api.heartbeatDeployment).not.toHaveBeenCalled();
49
+ });
50
+
51
+ it('calls heartbeatDeployment and prints confirmation', async () => {
52
+ api.heartbeatDeployment.mockResolvedValue({ deployment_id: 'dep-abc123', last_activity_at: 1700000000 });
53
+
54
+ const lines = [];
55
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
56
+
57
+ await heartbeatCommand(config, ['dep-abc123'], chalk);
58
+
59
+ expect(api.heartbeatDeployment).toHaveBeenCalledWith(config, 'dep-abc123');
60
+ expect(lines.some(l => l.includes('Heartbeat recorded for dep-abc123'))).toBe(true);
61
+ expect(process.exitCode).toBeFalsy();
62
+ });
63
+
64
+ it('reports an error and sets exitCode on failure', async () => {
65
+ api.heartbeatDeployment.mockRejectedValue(new Error('deployment not found'));
66
+
67
+ await heartbeatCommand(config, ['dep-missing'], chalk);
68
+
69
+ expect(process.exitCode).toBe(1);
70
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * badgr restart — relaunches an endpoint with the same config and API key
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ import { restartCommand } from '../src/commands/restart.js';
6
+
7
+ vi.mock('../src/api.js', () => ({
8
+ restartDeployment: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('../src/store.js', () => ({
12
+ findDeployment: vi.fn(() => null),
13
+ removeDeployment: vi.fn(),
14
+ addDeployment: vi.fn(),
15
+ addReceipt: vi.fn(),
16
+ generateReceiptId: vi.fn(() => 'rcpt-restart-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
+ beforeEach(() => {
38
+ process.exitCode = undefined;
39
+ vi.spyOn(console, 'log').mockImplementation(() => {});
40
+ vi.spyOn(console, 'error').mockImplementation(() => {});
41
+ vi.spyOn(process.stdout, 'write').mockImplementation(() => true);
42
+ vi.resetAllMocks();
43
+ store.findDeployment.mockReturnValue(null);
44
+ store.generateReceiptId.mockReturnValue('rcpt-restart-001');
45
+ });
46
+
47
+ afterEach(() => {
48
+ vi.restoreAllMocks();
49
+ process.exitCode = undefined;
50
+ });
51
+
52
+ it('requires a deployment id', async () => {
53
+ await restartCommand(config, [], chalk);
54
+ expect(api.restartDeployment).not.toHaveBeenCalled();
55
+ });
56
+
57
+ it('calls restartDeployment and prints the new deployment id and URL', async () => {
58
+ api.restartDeployment.mockResolvedValue({
59
+ deployment_id: 'dep-new-002',
60
+ workload_type: 'endpoint',
61
+ model: 'Qwen/Qwen2.5-7B-Instruct',
62
+ gpu_type: 'L40S',
63
+ gpu_count: 1,
64
+ status: 'provisioning',
65
+ endpoint_url: 'https://dep-new-002.aibadgr.com/v1',
66
+ cost_per_hour: 1.2,
67
+ });
68
+
69
+ const lines = [];
70
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
71
+
72
+ await restartCommand(config, ['dep-old-001'], chalk);
73
+
74
+ expect(api.restartDeployment).toHaveBeenCalledWith(config, 'dep-old-001');
75
+ expect(store.removeDeployment).toHaveBeenCalledWith('dep-old-001');
76
+ expect(store.addDeployment).toHaveBeenCalledWith(expect.objectContaining({ id: 'dep-new-002' }));
77
+ expect(lines.some(l => l.includes('dep-new-002'))).toBe(true);
78
+ expect(lines.some(l => l.includes('https://dep-new-002.aibadgr.com/v1'))).toBe(true);
79
+ expect(process.exitCode).toBeFalsy();
80
+ });
81
+
82
+ it('reports an error and sets exitCode on failure', async () => {
83
+ api.restartDeployment.mockRejectedValue(new Error('deployment not found'));
84
+
85
+ await restartCommand(config, ['dep-missing'], chalk);
86
+
87
+ expect(process.exitCode).toBe(1);
88
+ });
@@ -425,6 +425,78 @@ describe('custom image health check', () => {
425
425
  expect(process.exitCode).toBeFalsy();
426
426
  });
427
427
 
428
+ it('sends --idle-timeout to the backend as idle_timeout_minutes', async () => {
429
+ api.callApi
430
+ .mockResolvedValueOnce(makeServeDep())
431
+ .mockResolvedValueOnce({ status: 'running' })
432
+ .mockResolvedValueOnce(readyStatus());
433
+
434
+ const p = serveCommand(
435
+ config,
436
+ ['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '30', '--max-cost', '5'],
437
+ chalk,
438
+ );
439
+ await vi.advanceTimersByTimeAsync(5000);
440
+ await p;
441
+
442
+ const body = api.callApi.mock.calls[0][1].body;
443
+ expect(body.idle_timeout_minutes).toBe(30);
444
+ expect(process.exitCode).toBeFalsy();
445
+ });
446
+
447
+ it('omits idle_timeout_minutes when --idle-timeout is not passed', async () => {
448
+ api.callApi
449
+ .mockResolvedValueOnce(makeServeDep())
450
+ .mockResolvedValueOnce({ status: 'running' })
451
+ .mockResolvedValueOnce(readyStatus());
452
+
453
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
454
+ await vi.advanceTimersByTimeAsync(5000);
455
+ await p;
456
+
457
+ const body = api.callApi.mock.calls[0][1].body;
458
+ expect(body.idle_timeout_minutes).toBeUndefined();
459
+ });
460
+
461
+ it('prints the per-endpoint API key exactly once when the backend returns one', async () => {
462
+ api.callApi
463
+ .mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
464
+ .mockResolvedValueOnce({ status: 'running' })
465
+ .mockResolvedValueOnce(readyStatus());
466
+
467
+ const lines = [];
468
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
469
+
470
+ const p = serveCommand(config, ['meta-llama/Llama-3.1-8B-Instruct', '--max-cost', '5'], chalk);
471
+ await vi.advanceTimersByTimeAsync(5000);
472
+ await p;
473
+
474
+ const keyLine = lines.find(l => l.includes('API key:'));
475
+ expect(keyLine).toContain('bge_test_key_xyz');
476
+ const sdkLine = lines.find(l => l.includes('api_key='));
477
+ expect(sdkLine).toContain('bge_test_key_xyz');
478
+ });
479
+
480
+ it('prints a heartbeat reminder when --idle-timeout is set', async () => {
481
+ api.callApi
482
+ .mockResolvedValueOnce(makeServeDep({ endpoint_api_key: 'bge_test_key_xyz' }))
483
+ .mockResolvedValueOnce({ status: 'running' })
484
+ .mockResolvedValueOnce(readyStatus());
485
+
486
+ const lines = [];
487
+ console.log.mockImplementation((...args) => lines.push(args.join(' ')));
488
+
489
+ const p = serveCommand(
490
+ config,
491
+ ['meta-llama/Llama-3.1-8B-Instruct', '--idle-timeout', '15', '--max-cost', '5'],
492
+ chalk,
493
+ );
494
+ await vi.advanceTimersByTimeAsync(5000);
495
+ await p;
496
+
497
+ expect(lines.some(l => l.includes('badgr heartbeat dep-serve-001'))).toBe(true);
498
+ });
499
+
428
500
  it('--health-path also works for model-based serve (overrides /models default)', async () => {
429
501
  api.callApi
430
502
  .mockResolvedValueOnce(makeServeDep()) // POST /serve