badgr-cli 1.0.29 → 1.0.31

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/src/router.js CHANGED
@@ -1,26 +1,56 @@
1
1
  /**
2
- * GPU catalog static display info only (name, VRAM, tags, indicative rate).
2
+ * GPU catalog and routing preview.
3
3
  *
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.
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.
9
7
  */
10
8
 
11
9
  export const GPU_CATALOG = [
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: 'rtx-4090', canonical: 'RTX_4090', name: 'NVIDIA RTX 4090', vramGb: 24, ratePerHour: 1.10, tags: ['inference', 'training', 'dev'] },
15
- { id: 'a4000', canonical: 'A4000', name: 'NVIDIA RTX A4000', vramGb: 16, ratePerHour: 0.50, tags: ['inference', 'dev'] },
16
- { id: 'a5000', canonical: 'A5000', name: 'NVIDIA RTX A5000', vramGb: 24, ratePerHour: 0.70, tags: ['inference', 'dev'] },
17
- { id: 'l40s', canonical: 'L40S', name: 'NVIDIA L40S', vramGb: 48, ratePerHour: 1.40, tags: ['inference', 'training'] },
18
- { id: 'a6000', canonical: 'A6000', name: 'NVIDIA RTX A6000', vramGb: 48, ratePerHour: 1.60, tags: ['inference', 'training'] },
19
- { id: 'a100-40gb', canonical: 'A100', name: 'NVIDIA A100 40GB', vramGb: 40, ratePerHour: 1.80, tags: ['training', 'inference'] },
20
- { id: 'a100-80gb', canonical: 'A100_80GB', name: 'NVIDIA A100 80GB', vramGb: 80, ratePerHour: 2.50, tags: ['training', 'large-model'] },
21
- { id: 'h100', canonical: 'H100', name: 'NVIDIA H100 80GB', vramGb: 80, ratePerHour: 3.50, tags: ['training', 'large-model'] },
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'] },
22
17
  ];
23
18
 
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
+
24
54
  export function findById(id) {
25
55
  return GPU_CATALOG.find(g => g.id === id) ?? null;
26
56
  }
@@ -42,6 +72,33 @@ export function listAll() {
42
72
  return [...GPU_CATALOG].sort((a, b) => a.ratePerHour - b.ratePerHour);
43
73
  }
44
74
 
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
+
45
102
  export function estimateCost(ratePerHour, durationMinutes) {
46
103
  return (ratePerHour / 60) * durationMinutes;
47
104
  }
package/src/store.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Local deployment state — persisted to ~/.badgr/deployments.json.
2
+ * Local deployment state — persisted to ~/.gpu/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,15 +83,6 @@ 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
-
95
86
  export function listReceipts(limit = 20, storeFile = STORE_FILE) {
96
87
  return loadStore(storeFile).receipts.slice(0, limit);
97
88
  }
@@ -1,8 +1,6 @@
1
- import { describe, it, expect, vi } from 'vitest';
2
- import { parseRunArgs, classifyFailure } from '../src/commands/run.js';
1
+ import { describe, it, expect } from 'vitest';
2
+ import { parseRunArgs } 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 } from '../src/fallback.js';
6
4
 
7
5
  describe('parseRunArgs', () => {
8
6
  it('parses a plain command', () => {
@@ -45,124 +43,6 @@ describe('parseRunArgs', () => {
45
43
  expect(positional).toEqual([]);
46
44
  expect(flags.gpu).toBeUndefined();
47
45
  });
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
-
87
- describe('classifyFailure', () => {
88
- it('returns infrastructure when status is failed and no exit code', () => {
89
- expect(classifyFailure('failed', null)).toBe('infrastructure');
90
- expect(classifyFailure('failed', undefined)).toBe('infrastructure');
91
- });
92
-
93
- it('returns customer_code when exit code is non-zero', () => {
94
- expect(classifyFailure('failed', 1)).toBe('customer_code');
95
- expect(classifyFailure('completed', 2)).toBe('customer_code');
96
- });
97
-
98
- it('returns null for successful jobs', () => {
99
- expect(classifyFailure('completed', 0)).toBeNull();
100
- expect(classifyFailure('completed', null)).toBeNull();
101
- });
102
-
103
- it('returns null for stopped jobs with no exit code', () => {
104
- expect(classifyFailure('stopped', null)).toBeNull();
105
- });
106
- });
107
-
108
- describe('rankAlternatives', () => {
109
- const pool = [
110
- { gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
111
- { gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
112
- { gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
113
- { gpu: 'A6000', region: 'US', price: 1.20 }, // no rank field
114
- ];
115
-
116
- it('cheapest mode sorts by price ascending', () => {
117
- const ranked = rankAlternatives('A100', pool, 'cheapest');
118
- expect(ranked[0].price).toBe(0.72);
119
- expect(ranked[ranked.length - 1].price).toBe(2.80);
120
- });
121
-
122
- it('closest mode honours backend rank field when present', () => {
123
- const poolWithRanks = [
124
- { gpu: 'RTX_4090', region: 'US', price: 0.72, rank: 3 },
125
- { gpu: 'L40S', region: 'US', price: 1.25, rank: 2 },
126
- { gpu: 'H100', region: 'EU', price: 2.80, rank: 1 },
127
- ];
128
- const ranked = rankAlternatives('A100', poolWithRanks, 'closest');
129
- expect(ranked[0].gpu).toBe('H100'); // rank 1 → top
130
- expect(ranked[1].gpu).toBe('L40S'); // rank 2
131
- expect(ranked[2].gpu).toBe('RTX_4090'); // rank 3
132
- });
133
-
134
- it('closest mode falls back to price sort when no rank field', () => {
135
- const noRankPool = [
136
- { gpu: 'RTX_4090', region: 'US', price: 0.72 },
137
- { gpu: 'L40S', region: 'US', price: 1.25 },
138
- ];
139
- const ranked = rankAlternatives('A100', noRankPool, 'closest');
140
- expect(ranked[0].price).toBe(0.72);
141
- });
142
-
143
- it('returns empty array for empty pool', () => {
144
- expect(rankAlternatives('A100', [])).toEqual([]);
145
- });
146
-
147
- it('returns empty array for null pool', () => {
148
- expect(rankAlternatives('A100', null)).toEqual([]);
149
- });
150
- });
151
-
152
- describe('diffDescription', () => {
153
- it('uses diff_desc from alt object when backend provides it', () => {
154
- const alt = { diff_desc: 'less VRAM (24GB vs 40GB) than A100' };
155
- expect(diffDescription('A100', 'RTX_4090', alt)).toBe(alt.diff_desc);
156
- });
157
-
158
- it('returns empty string when diff_desc is absent', () => {
159
- expect(diffDescription('A100', 'RTX_4090', {})).toBe('');
160
- expect(diffDescription('A100', 'RTX_4090')).toBe('');
161
- });
162
-
163
- it('returns empty string for unknown GPUs with no alt object', () => {
164
- expect(diffDescription('UNKNOWN', 'ALSO_UNKNOWN')).toBe('');
165
- });
166
46
  });
167
47
 
168
48
  describe('parseServeArgs', () => {
@@ -198,65 +78,4 @@ describe('parseServeArgs', () => {
198
78
  const { model } = parseServeArgs(['--gpu', 'RTX_4090']);
199
79
  expect(model).toBeNull();
200
80
  });
201
-
202
- it('parses --tier 2 flag', () => {
203
- const { flags } = parseServeArgs(['my/model', '--tier', '2']);
204
- expect(flags.tier).toBe('2');
205
- });
206
-
207
- it('--tier defaults to undefined when not passed', () => {
208
- const { flags } = parseServeArgs(['my/model']);
209
- expect(flags.tier).toBeUndefined();
210
- });
211
- });
212
-
213
- describe('testCommand', () => {
214
- it('is a function', () => {
215
- expect(typeof testCommand).toBe('function');
216
- });
217
- });
218
-
219
- describe('parseTestArgs', () => {
220
- it('returns empty flags for no args', () => {
221
- expect(parseTestArgs([])).toEqual({});
222
- });
223
-
224
- it('parses --provider tier1', () => {
225
- expect(parseTestArgs(['--provider', 'tier1'])).toEqual({ provider: 'tier1' });
226
- });
227
-
228
- it('parses --provider tier2', () => {
229
- expect(parseTestArgs(['--provider', 'tier2'])).toEqual({ provider: 'tier2' });
230
- });
231
-
232
- it('parses --provider secondary', () => {
233
- expect(parseTestArgs(['--provider', 'secondary'])).toEqual({ provider: 'secondary' });
234
- });
235
-
236
- it('parses --no-tier-fallback', () => {
237
- expect(parseTestArgs(['--no-tier-fallback'])).toEqual({ noTierFallback: true });
238
- });
239
- });
240
-
241
- describe('promptFallback output', () => {
242
- it('does not print a Difference line', async () => {
243
- const lines = [];
244
- const chalk = { yellow: s => s, bold: s => s, cyan: s => s, green: s => s, dim: s => s };
245
- const origLog = console.log;
246
- console.log = (...args) => lines.push(args.join(' '));
247
- // force non-TTY so it auto-selects without asking
248
- const origIsTTY = process.stdin.isTTY;
249
- process.stdin.isTTY = false;
250
-
251
- const pool = [{ gpu: 'L40S', region: 'US', price: 1.25 }];
252
- await promptFallback('A100', pool, chalk);
253
-
254
- console.log = origLog;
255
- process.stdin.isTTY = origIsTTY;
256
-
257
- const joined = lines.join('\n');
258
- expect(joined).not.toContain('Difference');
259
- expect(joined).toContain('L40S');
260
- expect(joined).toContain('1.25');
261
- });
262
81
  });
@@ -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, normalizeBaseUrl, DEFAULTS } from '../src/config.js';
5
+ import { loadConfig, saveConfig, requireApiKey, 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,29 +58,6 @@ 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
-
84
61
  describe('requireApiKey', () => {
85
62
  it('returns the key when present', () => {
86
63
  expect(requireApiKey({ apiKey: 'sk-abc' })).toBe('sk-abc');
@@ -1,14 +1,10 @@
1
1
  import { describe, it, expect } from 'vitest';
2
2
  import {
3
3
  findById, findByCanonical, findCheapest, listAll,
4
- estimateCost,
5
- GPU_CATALOG,
4
+ getRoutePlan, estimateCost,
5
+ GPU_CATALOG, PROVIDER_CATALOG,
6
6
  } from '../src/router.js';
7
7
 
8
- // getRoutePlan and PROVIDER_CATALOG have been removed from the CLI.
9
- // Provider routing is now server-side only. Tests below cover the
10
- // remaining local GPU catalog helpers.
11
-
12
8
  describe('findById', () => {
13
9
  it('finds GPU by id', () => {
14
10
  expect(findById('rtx-4090').name).toContain('4090');
@@ -71,6 +67,43 @@ describe('listAll', () => {
71
67
  });
72
68
  });
73
69
 
70
+ describe('getRoutePlan', () => {
71
+ it('returns lane1 and lane2 for RTX_4090', () => {
72
+ const plan = getRoutePlan('RTX_4090');
73
+ expect(plan.lane1).toBeTruthy();
74
+ expect(plan.lane2.length).toBeGreaterThan(0);
75
+ });
76
+
77
+ it('lane2 is sorted cheapest-first', () => {
78
+ const plan = getRoutePlan('RTX_4090');
79
+ for (let i = 1; i < plan.lane2.length; i++) {
80
+ expect(plan.lane2[i].ratePerHour).toBeGreaterThanOrEqual(plan.lane2[i - 1].ratePerHour);
81
+ }
82
+ });
83
+
84
+ it('scales ratePerHour by count', () => {
85
+ const plan1 = getRoutePlan('RTX_4090', 1);
86
+ const plan2 = getRoutePlan('RTX_4090', 2);
87
+ expect(plan2.lane2[0].ratePerHour).toBeCloseTo(plan1.lane2[0].ratePerHour * 2, 5);
88
+ });
89
+
90
+ it('cheapestRate matches first provider rate', () => {
91
+ const plan = getRoutePlan('H100');
92
+ expect(plan.cheapestRate).toBe(plan.lane2[0].ratePerHour);
93
+ });
94
+
95
+ it('costWithOverhead > cheapestRate', () => {
96
+ const plan = getRoutePlan('RTX_4090');
97
+ expect(plan.costWithOverhead).toBeGreaterThan(plan.cheapestRate);
98
+ });
99
+
100
+ it('includes gpu info for known canonical', () => {
101
+ const plan = getRoutePlan('H100');
102
+ expect(plan.gpu).not.toBeNull();
103
+ expect(plan.gpu.id).toBe('h100');
104
+ });
105
+ });
106
+
74
107
  describe('estimateCost', () => {
75
108
  it('calculates cost for 60 minutes = 1 hour', () => {
76
109
  expect(estimateCost(1.10, 60)).toBeCloseTo(1.10, 5);
@@ -91,8 +124,34 @@ describe('L40S', () => {
91
124
  expect(findByCanonical('L40S').vramGb).toBe(48);
92
125
  });
93
126
 
94
- it('has no PROVIDER_CATALOG (provider routing is server-side)', async () => {
95
- const mod = await import('../src/router.js');
96
- expect(mod.PROVIDER_CATALOG).toBeUndefined();
127
+ it('has provider pricing in PROVIDER_CATALOG', () => {
128
+ expect(Array.isArray(PROVIDER_CATALOG['L40S'])).toBe(true);
129
+ expect(PROVIDER_CATALOG['L40S'].length).toBeGreaterThan(0);
130
+ });
131
+
132
+ it('route plan is cheapest-first for L40S', () => {
133
+ const plan = getRoutePlan('L40S');
134
+ expect(plan.lane2.length).toBeGreaterThan(0);
135
+ for (let i = 1; i < plan.lane2.length; i++) {
136
+ expect(plan.lane2[i].ratePerHour).toBeGreaterThanOrEqual(plan.lane2[i - 1].ratePerHour);
137
+ }
138
+ });
139
+ });
140
+
141
+ describe('PROVIDER_CATALOG', () => {
142
+ it('exists for all GPU_CATALOG canonical types', () => {
143
+ const catalogCanonicals = [...new Set(GPU_CATALOG.map(g => g.canonical))];
144
+ catalogCanonicals.forEach(c => {
145
+ // Some may not have provider pricing — just check the ones that do are valid arrays
146
+ if (PROVIDER_CATALOG[c]) {
147
+ expect(Array.isArray(PROVIDER_CATALOG[c])).toBe(true);
148
+ PROVIDER_CATALOG[c].forEach(p => {
149
+ expect(p.provider).toBeTruthy();
150
+ expect(p.ratePerHour).toBeGreaterThan(0);
151
+ expect(p.reliability).toBeGreaterThan(0);
152
+ expect(p.reliability).toBeLessThanOrEqual(1);
153
+ });
154
+ }
155
+ });
97
156
  });
98
157
  });
@@ -5,7 +5,7 @@ import { rmSync, existsSync } from 'fs';
5
5
  import {
6
6
  loadStore, saveStore,
7
7
  addDeployment, updateDeployment, removeDeployment, findDeployment, listDeployments,
8
- addReceipt, updateReceipt, listReceipts,
8
+ addReceipt, listReceipts,
9
9
  generateDeploymentId, generateReceiptId,
10
10
  } from '../src/store.js';
11
11
 
@@ -124,43 +124,3 @@ describe('addReceipt / listReceipts', () => {
124
124
  expect(listReceipts(3, file)).toHaveLength(3);
125
125
  });
126
126
  });
127
-
128
- describe('updateReceipt', () => {
129
- it('merges updates into an existing receipt', () => {
130
- addReceipt({ receiptId: 'r-upd', action: 'badgr run', status: 'running' }, file);
131
- updateReceipt('r-upd', { status: 'completed', finalCost: 0.012, runtimeSeconds: 42 }, file);
132
- const [r] = listReceipts(1, file);
133
- expect(r.status).toBe('completed');
134
- expect(r.finalCost).toBe(0.012);
135
- expect(r.runtimeSeconds).toBe(42);
136
- expect(r.action).toBe('badgr run'); // preserved
137
- });
138
-
139
- it('returns null for unknown receipt id', () => {
140
- expect(updateReceipt('no-such-id', { status: 'done' }, file)).toBeNull();
141
- });
142
-
143
- it('records a serve failure receipt with failureType=infrastructure', () => {
144
- addReceipt({
145
- receiptId: 'r-serve-fail',
146
- action: 'badgr serve',
147
- model: 'meta-llama/Llama-3.1-8B-Instruct',
148
- gpu: 'L40S',
149
- status: 'failed',
150
- failureType: 'infrastructure',
151
- createdAt: new Date().toISOString(),
152
- }, file);
153
- const [r] = listReceipts(1, file);
154
- expect(r.failureType).toBe('infrastructure');
155
- expect(r.action).toBe('badgr serve');
156
- expect(r.status).toBe('failed');
157
- });
158
-
159
- it('updates a serve receipt to health_check_timeout', () => {
160
- addReceipt({ receiptId: 'r-hc', action: 'badgr serve', status: 'provisioning' }, file);
161
- updateReceipt('r-hc', { status: 'health_check_timeout' }, file);
162
- const receipts = listReceipts(10, file);
163
- const r = receipts.find(x => x.receiptId === 'r-hc');
164
- expect(r.status).toBe('health_check_timeout');
165
- });
166
- });
@@ -1,93 +0,0 @@
1
- import { execSync } from 'child_process';
2
- import { requireApiKey } from '../config.js';
3
- import { callApi } from '../api.js';
4
-
5
- const BILLING_HELP = `
6
- badgr billing — manage your AI Badgr balance
7
-
8
- COMMANDS
9
- badgr billing status Show current balance
10
- badgr billing add <amount> Open checkout to add balance (minimum $10)
11
-
12
- EXAMPLES
13
- badgr billing status
14
- badgr billing add 10
15
- badgr billing add 20
16
- badgr billing add 50
17
- `;
18
-
19
- function openBrowser(url) {
20
- const platform = process.platform;
21
- try {
22
- if (platform === 'darwin') execSync(`open "${url}"`);
23
- else if (platform === 'win32') execSync(`start "" "${url}"`);
24
- else execSync(`xdg-open "${url}"`);
25
- } catch {
26
- // Silently ignore — we print the URL anyway
27
- }
28
- }
29
-
30
- async function billingStatus(config, chalk) {
31
- requireApiKey(config);
32
- try {
33
- const apiUrl = config.baseUrl.replace('/v1', '').replace('/api/v1', '');
34
- const data = await callApi('/api/me', {
35
- apiKey: config.apiKey,
36
- baseUrl: apiUrl,
37
- });
38
- const credits = data.credits ?? 0;
39
- const balanceUsd = (credits / 10000).toFixed(2);
40
- console.log();
41
- console.log(chalk.bold(' Balance'));
42
- console.log(` ${chalk.bold(chalk.blue(`$${balanceUsd}`))}`);
43
- console.log();
44
- if (credits === 0) {
45
- console.log(chalk.yellow(' Balance is $0.00. Add balance before making API calls or running GPU jobs.'));
46
- console.log(chalk.dim(' Add balance: badgr billing add 10'));
47
- console.log(chalk.dim(' Or visit: https://aibadgr.com/billing/top-up'));
48
- }
49
- console.log();
50
- } catch (err) {
51
- if (err.isPaymentRequired) {
52
- console.error(err.message);
53
- process.exit(1);
54
- }
55
- console.error(chalk.red(' Could not fetch balance: ' + err.message));
56
- process.exit(1);
57
- }
58
- }
59
-
60
- async function billingAdd(config, amount, chalk) {
61
- requireApiKey(config);
62
- const amountInt = parseInt(amount, 10);
63
- if (!amountInt || amountInt < 10) {
64
- console.error(chalk.red(' Minimum top-up is $10. Example: badgr billing add 10'));
65
- process.exit(1);
66
- }
67
-
68
- const url = `https://aibadgr.com/dashboard#billing`;
69
- console.log();
70
- console.log(chalk.bold(` Opening dashboard billing to add $${amountInt}...`));
71
- console.log();
72
- console.log(` ${chalk.dim(url)}`);
73
- console.log();
74
- openBrowser(url);
75
- console.log(chalk.dim(' Complete payment in your browser, then rerun your command.'));
76
- console.log();
77
- }
78
-
79
- export async function billingCommand(config, args, chalk) {
80
- const [sub, ...rest] = args;
81
-
82
- if (!sub || sub === '--help' || sub === '-h') {
83
- console.log(BILLING_HELP);
84
- return;
85
- }
86
-
87
- if (sub === 'status') return billingStatus(config, chalk);
88
- if (sub === 'add') return billingAdd(config, rest[0], chalk);
89
-
90
- console.error(chalk.red(` Unknown billing command: ${sub}`));
91
- console.log(chalk.dim(' Run: badgr billing --help'));
92
- process.exit(1);
93
- }