badgr-cli 1.0.31 → 1.0.34

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,219 @@
1
+ import readline from 'readline';
2
+ import { CATALOG, formatCliError } from './errors.js';
3
+
4
+ // ── Shared routing helpers ────────────────────────────────────────────────────
5
+
6
+ /** Rates above this threshold trigger a visible warning when no --max-cost is set. */
7
+ export const HIGH_RATE_THRESHOLD = 3.00;
8
+
9
+ /** Normalise --tier flag variants to '1' or '2'. */
10
+ export function normalizeTier(tier) {
11
+ return (tier === '2' || tier === 'tier2' || tier === 'tier-2') ? '2' : (tier || '1');
12
+ }
13
+
14
+ /**
15
+ * Sentinel error thrown when callWithFallback cannot provision capacity.
16
+ * The message is already formatted for display; callers should print it and exit.
17
+ */
18
+ export class CapacityError extends Error {
19
+ constructor(message) {
20
+ super(message);
21
+ this.name = 'CapacityError';
22
+ this.isCapacityError = true;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Call an API endpoint with automatic tier-2 expansion on NO_CAPACITY_MATCH.
28
+ * Returns the deployment object on success.
29
+ * Throws CapacityError (pre-formatted for display) on unrecoverable failure.
30
+ * Re-throws payment errors (err.isPaymentRequired) for callers to handle.
31
+ *
32
+ * @param {string} endpoint - '/run' or '/serve'
33
+ * @param {object} callOpts - { apiKey, baseUrl }
34
+ * @param {function} buildBody - (tierOverride?) => body object
35
+ * @param {string} effectiveTier
36
+ * @param {object} chalk
37
+ * @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
38
+ * @param {object} [opts]
39
+ * @param {boolean} [opts.allowTier2Fallback=true] - set false to disable tier-2 expansion
40
+ */
41
+ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels, opts = {}) {
42
+ const { callApi } = await import('./api.js');
43
+ const thing = labels?.thing ?? 'job';
44
+ const cmd = labels?.cmd ?? 'badgr run';
45
+ const allowTier2Fallback = opts.allowTier2Fallback !== false; // default true
46
+
47
+ // 75s: backend provisioning runs up to 55s and returns PROVISIONING_TIMEOUT;
48
+ // the extra headroom ensures the CLI always sees the real response.
49
+ async function attempt(body) {
50
+ return callApi(endpoint, { method: 'POST', ...callOpts, body, timeoutMs: 75_000 });
51
+ }
52
+
53
+ // Map server error codes → catalog keys and extract context for templates.
54
+ function buildCapacityError(err, isFallback) {
55
+ const d = err.errorData ?? {};
56
+ const code = d.code ?? '';
57
+
58
+ // Internal fields: shown only with BADGR_DEBUG=1
59
+ const internal = {
60
+ ...(d.debug_error ? { detail: d.debug_error } : {}),
61
+ ...(d.filters ? { filters: JSON.stringify(d.filters) } : {}),
62
+ };
63
+
64
+ // Map server codes → catalog keys + context
65
+ const serverToKey = {
66
+ NO_CAPACITY: 'NO_CAPACITY',
67
+ NO_CAPACITY_MATCH: 'NO_CAPACITY',
68
+ PROVISIONING_TIMEOUT: 'PROVISIONING_TIMEOUT',
69
+ PROVISIONING_FAILED: 'PROVISIONING_FAILED',
70
+ PROVIDER_ADAPTER_ERROR:'PROVISIONING_FAILED',
71
+ INVALID_GPU: 'INVALID_GPU',
72
+ INVALID_MODEL: 'INVALID_MODEL',
73
+ INSUFFICIENT_VRAM: 'INSUFFICIENT_VRAM',
74
+ MAX_COST_TOO_LOW: 'MAX_COST_TOO_LOW',
75
+ };
76
+
77
+ const key = serverToKey[code];
78
+ if (key) {
79
+ const ctx = {
80
+ gpu: d.filters?.gpu ?? d.gpu,
81
+ region: d.filters?.region,
82
+ failure_category: d.failure_category,
83
+ low_cost_failed: d.low_cost_provider_failed,
84
+ server_message: d.message,
85
+ alternatives: d.alternatives,
86
+ };
87
+ return new CapacityError(formatCliError(key, ctx, chalk, internal));
88
+ }
89
+
90
+ // Unknown / generic error — safe fallback with no internal details exposed
91
+ const fallbackMsg =
92
+ chalk.red(`\n ✗ Could not start ${thing}: ${err.message}\n`) +
93
+ chalk.dim(` Run BADGR_DEBUG=1 ${cmd} … for a full trace.`) +
94
+ (!isFallback ? '\n' + chalk.dim(' Check config: badgr config\n') : '');
95
+ return new CapacityError(fallbackMsg);
96
+ }
97
+
98
+ // First attempt
99
+ let firstErr;
100
+ try {
101
+ return await attempt(buildBody());
102
+ } catch (err) {
103
+ if (err.isPaymentRequired) throw err;
104
+ firstErr = err;
105
+ }
106
+
107
+ const d = firstErr.errorData;
108
+
109
+ // Stale-capacity retry: PROVISIONING_FAILED means a GPU slot appeared available but
110
+ // couldn't launch. Retry once immediately to pick up a fresh slot before escalating.
111
+ if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
112
+ console.log(chalk.dim('\n Provisioning failed — retrying with fresh capacity...\n'));
113
+ try {
114
+ return await attempt(buildBody());
115
+ } catch (retryErr) {
116
+ if (retryErr.isPaymentRequired) throw retryErr;
117
+ firstErr = retryErr;
118
+ }
119
+ }
120
+
121
+ // Tier-2 expansion: try budget-tier providers when primary has no capacity
122
+ if (firstErr.errorData?.code === 'NO_CAPACITY_MATCH' && effectiveTier !== '2' && allowTier2Fallback) {
123
+ console.log(chalk.dim('\n Primary capacity unavailable — expanding search...\n'));
124
+ try {
125
+ return await attempt(buildBody('2'));
126
+ } catch (err2) {
127
+ throw buildCapacityError(err2, true);
128
+ }
129
+ }
130
+
131
+ throw buildCapacityError(firstErr, false);
132
+ }
133
+
134
+ // ── GPU fallback prompt ───────────────────────────────────────────────────────
135
+
136
+ // GPU descriptions for display only — no scoring logic lives here.
137
+ const GPU_DISPLAY = {
138
+ RTX_3080: { desc: 'Dev, light inference' },
139
+ RTX_3090: { desc: 'Dev, inference' },
140
+ RTX_4080: { desc: 'Inference and dev workloads' },
141
+ RTX_4090: { desc: 'Inference, training, dev' },
142
+ L40S: { desc: 'Inference, vLLM, batch jobs' },
143
+ A6000: { desc: 'Training, large models, inference' },
144
+ A100: { desc: 'Large-scale training and inference' },
145
+ H100: { desc: 'Large model training, best throughput' },
146
+ };
147
+
148
+ /**
149
+ * Sort alternatives for display.
150
+ */
151
+ export function rankAlternatives(requestedGpu, alternatives, mode = 'closest') {
152
+ if (!alternatives || alternatives.length === 0) return [];
153
+ if (mode === 'cheapest') return [...alternatives].sort((a, b) => a.price - b.price);
154
+ if (alternatives.some(a => a.rank != null)) {
155
+ return [...alternatives].sort((a, b) => (a.rank ?? 999) - (b.rank ?? 999));
156
+ }
157
+ return [...alternatives].sort((a, b) => a.price - b.price);
158
+ }
159
+
160
+ /**
161
+ * One-line diff for display.
162
+ */
163
+ export function diffDescription(requestedGpu, altGpu, alt = {}) {
164
+ return alt.diff_desc || '';
165
+ }
166
+
167
+ function ask(prompt) {
168
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
169
+ return new Promise(resolve => rl.question(prompt, ans => { rl.close(); resolve(ans.trim()); }));
170
+ }
171
+
172
+ /**
173
+ * Show the interactive fallback prompt and return the chosen alternative or null.
174
+ * Non-TTY environments auto-select the top-ranked option.
175
+ */
176
+ export async function promptFallback(requestedGpu, ranked, chalk) {
177
+ if (ranked.length === 0) return null;
178
+
179
+ const top = ranked[0];
180
+ const others = ranked.slice(1, 4);
181
+ const topDisp = GPU_DISPLAY[top.gpu];
182
+
183
+ console.log(chalk.yellow(`\n ${requestedGpu} isn't available right now.\n`));
184
+ console.log(chalk.bold(' Closest match:'));
185
+ console.log(` ${chalk.cyan(top.gpu)} in ${top.region}`);
186
+ console.log(` ${chalk.green('$' + top.price.toFixed(2) + '/hr')} estimated price`);
187
+ if (top.diff_desc) console.log(` ${chalk.dim(top.diff_desc)}`);
188
+ else if (topDisp?.desc) console.log(` ${topDisp.desc}`);
189
+ console.log(chalk.dim(' (availability estimated from market data — not pre-verified)'));
190
+ console.log();
191
+
192
+ if (!process.stdin.isTTY) {
193
+ console.log(chalk.dim(` Auto-selecting ${top.gpu} (non-interactive).`));
194
+ return top;
195
+ }
196
+
197
+ console.log(` Press ${chalk.bold('Enter')} to run on ${chalk.cyan(top.gpu)}`);
198
+ if (others.length > 0) {
199
+ console.log(' or type:');
200
+ for (const [i, alt] of others.entries()) {
201
+ const disp = GPU_DISPLAY[alt.gpu];
202
+ const hint = disp ? disp.desc.split(',')[0].toLowerCase() : '';
203
+ console.log(` ${chalk.bold(String(i + 1))} = ${alt.gpu}${hint ? ', ' + hint : ''}`);
204
+ }
205
+ }
206
+ console.log(` ${chalk.bold('q')} = cancel`);
207
+ console.log();
208
+
209
+ const answer = await ask(' > ');
210
+
211
+ if (answer === '') return top;
212
+ if (answer.toLowerCase() === 'q') return null;
213
+
214
+ const idx = parseInt(answer, 10);
215
+ if (!isNaN(idx) && idx >= 1 && idx <= others.length) return others[idx - 1];
216
+
217
+ console.log(chalk.dim(` Unrecognised input — using ${top.gpu}.`));
218
+ return top;
219
+ }
package/src/router.js CHANGED
@@ -1,56 +1,27 @@
1
1
  /**
2
- * GPU catalog and routing preview.
2
+ * GPU catalog static display info only (name, VRAM, tags, indicative rate).
3
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.
4
+ * ratePerHour values are indicative market averages for reference display.
5
+ * Actual billing is always set by the backend and returned as cost_per_hour
6
+ * on every deployment response. For live rates, use GET /v1/gpus.
7
+ *
8
+ * Provider routing logic lives entirely in the backend.
7
9
  */
8
10
 
9
11
  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'] },
12
+ { id: 'rtx-3080', canonical: 'RTX_3080', name: 'NVIDIA RTX 3080', vramGb: 10, ratePerHour: 0.35, tags: ['inference', 'dev'] },
13
+ { id: 'rtx-3090', canonical: 'RTX_3090', name: 'NVIDIA RTX 3090', vramGb: 24, ratePerHour: 0.60, tags: ['inference', 'dev'] },
14
+ { id: 'a4000', canonical: 'A4000', name: 'NVIDIA RTX A4000', vramGb: 16, ratePerHour: 0.50, tags: ['inference', 'dev'] },
15
+ { id: 'a5000', canonical: 'A5000', name: 'NVIDIA RTX A5000', vramGb: 24, ratePerHour: 0.70, tags: ['inference', 'dev'] },
16
+ { id: 'rtx-4080', canonical: 'RTX_4080', name: 'NVIDIA RTX 4080', vramGb: 16, ratePerHour: 0.75, tags: ['inference', 'dev'] },
17
+ { id: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
18
+ { id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
19
+ { id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
20
+ { id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
21
+ { id: 'a100-80gb', canonical: 'A100_80GB', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
22
+ { id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
17
23
  ];
18
24
 
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
25
  export function findById(id) {
55
26
  return GPU_CATALOG.find(g => g.id === id) ?? null;
56
27
  }
@@ -72,33 +43,6 @@ export function listAll() {
72
43
  return [...GPU_CATALOG].sort((a, b) => a.ratePerHour - b.ratePerHour);
73
44
  }
74
45
 
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
46
  export function estimateCost(ratePerHour, durationMinutes) {
103
47
  return (ratePerHour / 60) * durationMinutes;
104
48
  }
package/src/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Local deployment state — persisted to ~/.gpu/deployments.json.
2
+ * Local deployment state — persisted to ~/.badgr/deployments.json.
3
3
  *
4
4
  * Tracks what `gpu up` has provisioned so `gpu down/status/logs/receipts`
5
5
  * have something to work with before a backend deployments API exists.
@@ -83,6 +83,15 @@ export function addReceipt(receipt, storeFile = STORE_FILE) {
83
83
  return receipt;
84
84
  }
85
85
 
86
+ export function updateReceipt(receiptId, updates, storeFile = STORE_FILE) {
87
+ const store = loadStore(storeFile);
88
+ const idx = store.receipts.findIndex(r => r.receiptId === receiptId);
89
+ if (idx === -1) return null;
90
+ store.receipts[idx] = { ...store.receipts[idx], ...updates };
91
+ saveStore(store, storeFile);
92
+ return store.receipts[idx];
93
+ }
94
+
86
95
  export function listReceipts(limit = 20, storeFile = STORE_FILE) {
87
96
  return loadStore(storeFile).receipts.slice(0, limit);
88
97
  }
@@ -1,6 +1,8 @@
1
- import { describe, it, expect } from 'vitest';
2
- import { parseRunArgs } from '../src/commands/run.js';
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
3
3
  import { parseServeArgs } from '../src/commands/serve.js';
4
+ import { testCommand, parseTestArgs } from '../src/commands/test-run.js';
5
+ import { rankAlternatives, diffDescription, promptFallback, CapacityError } from '../src/fallback.js';
4
6
 
5
7
  describe('parseRunArgs', () => {
6
8
  it('parses a plain command', () => {
@@ -43,6 +45,136 @@ describe('parseRunArgs', () => {
43
45
  expect(positional).toEqual([]);
44
46
  expect(flags.gpu).toBeUndefined();
45
47
  });
48
+
49
+ it('parses --fallback closest', () => {
50
+ const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--fallback', 'closest']);
51
+ expect(flags.fallback).toBe('closest');
52
+ expect(flags.noFallback).toBeUndefined();
53
+ });
54
+
55
+ it('parses --fallback cheapest', () => {
56
+ const { flags } = parseRunArgs(['python', 'train.py', '--fallback', 'cheapest']);
57
+ expect(flags.fallback).toBe('cheapest');
58
+ });
59
+
60
+ it('parses --no-fallback', () => {
61
+ const { flags } = parseRunArgs(['python', 'train.py', '--gpu', 'A100', '--no-fallback']);
62
+ expect(flags.noFallback).toBe(true);
63
+ expect(flags.fallback).toBeUndefined();
64
+ });
65
+
66
+ it('parses --max-runtime as a float (minutes)', () => {
67
+ const { flags } = parseRunArgs(['python', 'train.py', '--max-runtime', '30']);
68
+ expect(flags.maxRuntime).toBe(30);
69
+ });
70
+
71
+ it('parses --max-cost as a float (dollars)', () => {
72
+ const { flags } = parseRunArgs(['python', 'train.py', '--max-cost', '5.00']);
73
+ expect(flags.maxCost).toBe(5.0);
74
+ });
75
+
76
+ it('parses --tier 2 flag', () => {
77
+ const { flags } = parseRunArgs(['python', 'train.py', '--tier', '2']);
78
+ expect(flags.tier).toBe('2');
79
+ });
80
+
81
+ it('--tier defaults to undefined when not passed', () => {
82
+ const { flags } = parseRunArgs(['python', 'train.py']);
83
+ expect(flags.tier).toBeUndefined();
84
+ });
85
+
86
+ it('parses --min-vram flag', () => {
87
+ const { flags } = parseRunArgs(['python', 'job.py', '--min-vram', '24']);
88
+ expect(flags.minVram).toBe(24);
89
+ expect(flags.gpu).toBeUndefined();
90
+ });
91
+
92
+ it('--min-vram and --gpu can be combined', () => {
93
+ const { flags } = parseRunArgs(['python', 'job.py', '--gpu', 'A100', '--min-vram', '40']);
94
+ expect(flags.gpu).toBe('A100');
95
+ expect(flags.minVram).toBe(40);
96
+ });
97
+ });
98
+
99
+ describe('classifyFailure', () => {
100
+ it('returns infrastructure when status is failed and no exit code', () => {
101
+ expect(classifyFailure('failed', null)).toBe('infrastructure');
102
+ expect(classifyFailure('failed', undefined)).toBe('infrastructure');
103
+ });
104
+
105
+ it('returns customer_code when exit code is non-zero', () => {
106
+ expect(classifyFailure('failed', 1)).toBe('customer_code');
107
+ expect(classifyFailure('completed', 2)).toBe('customer_code');
108
+ });
109
+
110
+ it('returns null for successful jobs', () => {
111
+ expect(classifyFailure('completed', 0)).toBeNull();
112
+ expect(classifyFailure('completed', null)).toBeNull();
113
+ });
114
+
115
+ it('returns null for stopped jobs with no exit code', () => {
116
+ expect(classifyFailure('stopped', null)).toBeNull();
117
+ });
118
+ });
119
+
120
+ describe('rankAlternatives', () => {
121
+ const pool = [
122
+ { gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
123
+ { gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
124
+ { gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
125
+ { gpu: 'A6000', region: 'US', price: 1.20 }, // no rank field
126
+ ];
127
+
128
+ it('cheapest mode sorts by price ascending', () => {
129
+ const ranked = rankAlternatives('A100', pool, 'cheapest');
130
+ expect(ranked[0].price).toBe(0.72);
131
+ expect(ranked[ranked.length - 1].price).toBe(2.80);
132
+ });
133
+
134
+ it('closest mode honours backend rank field when present', () => {
135
+ const poolWithRanks = [
136
+ { gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
137
+ { gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
138
+ { gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
139
+ ];
140
+ const ranked = rankAlternatives('A100', poolWithRanks, 'closest');
141
+ expect(ranked[0].gpu).toBe('H100'); // rank 1 → top
142
+ expect(ranked[1].gpu).toBe('L40S'); // rank 2
143
+ expect(ranked[2].gpu).toBe('RTX_4090'); // rank 3
144
+ });
145
+
146
+ it('closest mode falls back to price sort when no rank field', () => {
147
+ const noRankPool = [
148
+ { gpu: 'RTX_4090', region: 'US', price: 0.72 },
149
+ { gpu: 'L40S', region: 'US', price: 1.25 },
150
+ ];
151
+ const ranked = rankAlternatives('A100', noRankPool, 'closest');
152
+ expect(ranked[0].price).toBe(0.72);
153
+ });
154
+
155
+ it('returns empty array for empty pool', () => {
156
+ expect(rankAlternatives('A100', [])).toEqual([]);
157
+ });
158
+
159
+ it('returns empty array for null pool', () => {
160
+ expect(rankAlternatives('A100', null)).toEqual([]);
161
+ });
162
+ });
163
+
164
+ describe('diffDescription', () => {
165
+ it('uses diff_desc from alt object when backend provides it', () => {
166
+ const alt = { diff_desc: 'less VRAM (24GB vs 40GB) than A100' };
167
+ expect(diffDescription('A100', 'RTX_4090', alt)).toBe(alt.diff_desc);
168
+ });
169
+
170
+ it('returns empty string when diff_desc is absent', () => {
171
+ expect(diffDescription('A100', 'RTX_4090', {})).toBe('');
172
+ expect(diffDescription('A100', 'RTX_4090')).toBe('');
173
+ });
174
+
175
+ it('returns empty string for unknown GPUs with no alt object', () => {
176
+ expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
177
+ });
46
178
  });
47
179
 
48
180
  describe('parseServeArgs', () => {
@@ -78,4 +210,116 @@ describe('parseServeArgs', () => {
78
210
  const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
79
211
  expect(model).toBeNull();
80
212
  });
213
+
214
+ it('parses --tier 2 flag', () => {
215
+ const { flags } = parseServeArgs(['my/model', '--tier', '2']);
216
+ expect(flags.tier).toBe('2');
217
+ });
218
+
219
+ it('--tier defaults to undefined when not passed', () => {
220
+ const { flags } = parseServeArgs(['my/model']);
221
+ expect(flags.tier).toBeUndefined();
222
+ });
223
+
224
+ it('parses --no-fallback as noMarketplaceFallback', () => {
225
+ const { flags } = parseServeArgs(['my/model', '--no-fallback']);
226
+ expect(flags.noMarketplaceFallback).toBe(true);
227
+ });
228
+
229
+ it('parses --strict-capacity as noMarketplaceFallback', () => {
230
+ const { flags } = parseServeArgs(['my/model', '--strict-capacity']);
231
+ expect(flags.noMarketplaceFallback).toBe(true);
232
+ });
233
+
234
+ it('parses --no-expanded-search as noMarketplaceFallback', () => {
235
+ const { flags } = parseServeArgs(['my/model', '--no-expanded-search']);
236
+ expect(flags.noMarketplaceFallback).toBe(true);
237
+ });
238
+
239
+ it('noMarketplaceFallback defaults to falsy when not passed', () => {
240
+ const { flags } = parseServeArgs(['my/model']);
241
+ expect(flags.noMarketplaceFallback).toBeFalsy();
242
+ });
243
+
244
+ it('model is not consumed by --no-fallback flag', () => {
245
+ const { model, flags } = parseServeArgs(['my/model', '--no-fallback']);
246
+ expect(model).toBe('my/model');
247
+ expect(flags.noMarketplaceFallback).toBe(true);
248
+ });
249
+
250
+ it('parses --health-path flag', () => {
251
+ const { flags } = parseServeArgs(['my/model', '--health-path', '/system_stats']);
252
+ expect(flags.healthPath).toBe('/system_stats');
253
+ });
254
+
255
+ it('parses --health-path with arbitrary path', () => {
256
+ const { flags } = parseServeArgs(['--image', 'my/image:latest', '--health-path', '/health']);
257
+ expect(flags.healthPath).toBe('/health');
258
+ });
259
+
260
+ it('healthPath defaults to undefined when not passed', () => {
261
+ const { flags } = parseServeArgs(['my/model']);
262
+ expect(flags.healthPath).toBeUndefined();
263
+ });
264
+ });
265
+
266
+ describe('testCommand', () => {
267
+ it('is a function', () => {
268
+ expect(typeof testCommand).toBe('function');
269
+ });
270
+ });
271
+
272
+ describe('parseTestArgs', () => {
273
+ it('returns empty flags for no args', () => {
274
+ expect(parseTestArgs([])).toEqual({});
275
+ });
276
+
277
+ it('parses --provider tier1', () => {
278
+ expect(parseTestArgs(['--provider', 'tier1'])).toEqual({ provider: 'tier1' });
279
+ });
280
+
281
+ it('parses --provider tier2', () => {
282
+ expect(parseTestArgs(['--provider', 'tier2'])).toEqual({ provider: 'tier2' });
283
+ });
284
+
285
+ it('parses --provider secondary', () => {
286
+ expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
287
+ });
288
+
289
+ it('parses --no-tier-fallback', () => {
290
+ expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
291
+ });
292
+ });
293
+
294
+ describe('CapacityError', () => {
295
+ it('is an Error subclass', () => {
296
+ const err = new CapacityError('no capacity');
297
+ expect(err).toBeInstanceOf(Error);
298
+ expect(err.isCapacityError).toBe(true);
299
+ expect(err.name).toBe('CapacityError');
300
+ expect(err.message).toBe('no capacity');
301
+ });
302
+ });
303
+
304
+ describe('promptFallback output', () => {
305
+ it('does not print a Difference line', async () => {
306
+ const lines = [];
307
+ const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
308
+ const origLog = console.log;
309
+ console.log = (...args) => lines.push(args.join(' '));
310
+ // force non-TTY so it auto-selects without asking
311
+ const origIsTTY = process.stdin.isTTY;
312
+ process.stdin.isTTY = false;
313
+
314
+ const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
315
+ await promptFallback('A100', pool, chalk);
316
+
317
+ console.log = origLog;
318
+ process.stdin.isTTY = origIsTTY;
319
+
320
+ const joined = lines.join('\n');
321
+ expect(joined).not.toContain('Difference');
322
+ expect(joined).toContain('L40S');
323
+ expect(joined).toContain('1.25');
324
+ });
81
325
  });
@@ -2,7 +2,7 @@ import { describe, it, expect, afterEach } from 'vitest';
2
2
  import { tmpdir } from 'os';
3
3
  import { join } from 'path';
4
4
  import { rmSync, existsSync } from 'fs';
5
- import { loadConfig, saveConfig, requireApiKey, DEFAULTS } from '../src/config.js';
5
+ import { loadConfig, saveConfig, requireApiKey, normalizeBaseUrl, DEFAULTS } from '../src/config.js';
6
6
 
7
7
  const tmp = join(tmpdir(), `badgr-cli-test-${process.pid}`);
8
8
  const testConfigFile = join(tmp, 'config.json');
@@ -58,6 +58,29 @@ describe('saveConfig', () => {
58
58
  });
59
59
  });
60
60
 
61
+ describe('normalizeBaseUrl', () => {
62
+ it('rewrites legacy api.badgr.ai to production host', () => {
63
+ expect(normalizeBaseUrl('https://api.badgr.ai/v1')).toBe(DEFAULTS.baseUrl);
64
+ expect(normalizeBaseUrl('https://api.badgr.ai')).toBe(DEFAULTS.baseUrl);
65
+ });
66
+
67
+ it('keeps production and localhost URLs', () => {
68
+ expect(normalizeBaseUrl('https://api.aibadgr.com/v1')).toBe(DEFAULTS.baseUrl);
69
+ expect(normalizeBaseUrl('http://localhost:8000/v1')).toBe('http://localhost:8000/v1');
70
+ });
71
+ });
72
+
73
+ describe('loadConfig legacy migration', () => {
74
+ it('migrates saved api.badgr.ai baseUrl on load', () => {
75
+ saveConfig(
76
+ { apiKey: 'sk-test', baseUrl: 'https://api.badgr.ai/v1' },
77
+ testConfigFile,
78
+ );
79
+ const config = loadConfig(testConfigFile);
80
+ expect(config.baseUrl).toBe(DEFAULTS.baseUrl);
81
+ });
82
+ });
83
+
61
84
  describe('requireApiKey', () => {
62
85
  it('returns the key when present', () => {
63
86
  expect(requireApiKey({ apiKey: 'sk-abc' })).toBe('sk-abc');