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 +1 -1
- package/src/api.js +18 -0
- package/src/badgr.js +6 -0
- package/src/commands/heartbeat.js +38 -0
- package/src/commands/restart.js +74 -0
- package/src/commands/serve.js +26 -4
- package/tests/heartbeat.test.js +70 -0
- package/tests/restart.test.js +88 -0
- package/tests/serve-lifecycle.test.js +72 -0
package/package.json
CHANGED
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
|
+
}
|
package/src/commands/serve.js
CHANGED
|
@@ -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
|
-
|
|
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 ${
|
|
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="${
|
|
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
|