badgr-cli 1.1.2 → 1.1.3
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/README.md +4 -4
- package/package.json +1 -1
- package/src/badgr.js +24 -8
- package/src/commands/billing.js +3 -3
- package/src/commands/connect.js +4 -1
- package/src/commands/diagnose.js +493 -0
- package/src/commands/job.js +246 -0
- package/src/commands/launch.js +144 -15
- package/src/commands/run.js +29 -3
- package/src/credentials.js +33 -0
- package/src/fallback.js +13 -2
- package/src/commands/task.js +0 -25
package/README.md
CHANGED
|
@@ -103,13 +103,13 @@ Everything before `--` is a `badgr launch` flag; everything after `--` is passed
|
|
|
103
103
|
| `--region US\|EU\|AU` | — | Region preference |
|
|
104
104
|
| `--size small\|medium\|browser` | per-workload default | VM class override |
|
|
105
105
|
|
|
106
|
-
### `badgr
|
|
106
|
+
### `badgr job` — a tracked coding-agent job
|
|
107
107
|
|
|
108
108
|
```bash
|
|
109
|
-
badgr
|
|
109
|
+
badgr job cline "Run the Chromium tests and tell me what failed" --check "npm run test:chromium" --max-cost 1
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
|
|
112
|
+
Submits via `POST /v1/jobs` (`type: "agent"`) — the same Jobs API used by every other job type. Requires `--check <command>` (verifies success); tracked at `/jobs` with a `job_id`, status, logs, output, cost, and time.
|
|
113
113
|
|
|
114
114
|
---
|
|
115
115
|
|
|
@@ -185,7 +185,7 @@ test
|
|
|
185
185
|
| `badgr doctor` | Diagnose a GPU workload failure — read-only, no login needed |
|
|
186
186
|
| `badgr run <command>` | Run a one-off GPU job (any container command) |
|
|
187
187
|
| `badgr launch cline\|claude\|codex\|playwright "<task>"` | Run a coding/testing agent on a CPU VM — image + command auto-selected |
|
|
188
|
-
| `badgr
|
|
188
|
+
| `badgr job <agent> "<instruction>" --check "<cmd>"` | Tracked coding-agent job via `POST /v1/jobs` (type: agent) |
|
|
189
189
|
| `badgr serve <model>` | Start a persistent OpenAI-compatible endpoint |
|
|
190
190
|
| `badgr status` | Show what's running and what's billing |
|
|
191
191
|
| `badgr logs <id>` | Fetch log output from a deployment |
|
package/package.json
CHANGED
package/src/badgr.js
CHANGED
|
@@ -9,7 +9,7 @@ import { logsCommand } from './commands/logs.js';
|
|
|
9
9
|
import { receiptsCommand } from './commands/receipts.js';
|
|
10
10
|
import { runCommand } from './commands/run.js';
|
|
11
11
|
import { launchCommand } from './commands/launch.js';
|
|
12
|
-
import {
|
|
12
|
+
import { jobCommand } from './commands/job.js';
|
|
13
13
|
import { artifactsCommand } from './commands/artifacts.js';
|
|
14
14
|
import { serveCommand } from './commands/serve.js';
|
|
15
15
|
import { modelsCommand } from './commands/models.js';
|
|
@@ -31,6 +31,7 @@ import { rerunCommand } from './commands/rerun.js';
|
|
|
31
31
|
import { heartbeatCommand } from './commands/heartbeat.js';
|
|
32
32
|
import { pullCommand } from './commands/pull.js';
|
|
33
33
|
import { doctorCommand } from './commands/doctor.js';
|
|
34
|
+
import { diagnoseCommand } from './commands/diagnose.js';
|
|
34
35
|
import { connectCommand } from './commands/connect.js';
|
|
35
36
|
|
|
36
37
|
const HELP = `
|
|
@@ -43,10 +44,11 @@ ${chalk.bold('COMMANDS')}
|
|
|
43
44
|
${chalk.cyan('badgr run <command>')} Run a one-off GPU job
|
|
44
45
|
${chalk.cyan('badgr launch cline|claude|codex|playwright "<task>"')} Run a coding/testing workload on a CPU VM — image + command auto-selected
|
|
45
46
|
${chalk.cyan('badgr launch <source> -- <command>')} Advanced escape hatch: run any other command on a CPU VM
|
|
46
|
-
${chalk.cyan('badgr
|
|
47
|
+
${chalk.cyan('badgr job <agent> "<instruction>" --check "<command>"')} Bounded coding-agent job, tracked at /jobs (POST /v1/jobs, type: agent)
|
|
47
48
|
${chalk.cyan('badgr serve <model>')} Serve a model with an OpenAI-compatible endpoint
|
|
48
49
|
${chalk.cyan('badgr serve openwebui')} Serve Open WebUI — chat UI, connects to a model endpoint
|
|
49
|
-
${chalk.cyan('badgr
|
|
50
|
+
${chalk.cyan('badgr diagnose "<input>"')} Diagnose any GPU issue for free — GitHub issue, Docker image, log, repo, or text
|
|
51
|
+
${chalk.cyan('badgr doctor')} Local GPU / model-fit diagnosis (read-only, no login needed)
|
|
50
52
|
${chalk.cyan('badgr status')} Show what's running and what's billing
|
|
51
53
|
${chalk.cyan('badgr logs <id>')} Stream logs for a running job or endpoint
|
|
52
54
|
${chalk.cyan('badgr pull <id>')} Safely pull cloud-agent patch artifacts
|
|
@@ -108,10 +110,18 @@ ${chalk.bold('EXAMPLES')}
|
|
|
108
110
|
badgr launch https://github.com/user/repo --max-cost 1 -- python narrgo.py
|
|
109
111
|
badgr artifacts <id>
|
|
110
112
|
|
|
111
|
-
${chalk.dim('# badgr
|
|
112
|
-
badgr
|
|
113
|
+
${chalk.dim('# badgr job — bounded coding-agent job with a pass/fail check, tracked at /jobs:')}
|
|
114
|
+
badgr job cline "Run the Chromium tests and tell me what failed" --check "npm run test:chromium" --max-cost 1
|
|
113
115
|
|
|
114
|
-
${chalk.dim('# Diagnose
|
|
116
|
+
${chalk.dim('# Diagnose any GPU issue for free (no login needed):')}
|
|
117
|
+
badgr diagnose "https://github.com/org/repo/issues/123"
|
|
118
|
+
badgr diagnose ./error.log
|
|
119
|
+
badgr diagnose --docker ajayrajtp/vllm_gemma412b:latest
|
|
120
|
+
badgr diagnose --repo https://github.com/org/repo
|
|
121
|
+
badgr diagnose --comfyui workflow.json
|
|
122
|
+
badgr diagnose "https://github.com/org/repo/issues/123" --approve ${chalk.dim('# approve a capped smoke test')}
|
|
123
|
+
|
|
124
|
+
${chalk.dim('# Local model-fit and workload diagnosis (no network):')}
|
|
115
125
|
badgr doctor
|
|
116
126
|
badgr doctor --model Qwen/Qwen2.5-7B-Instruct --serve
|
|
117
127
|
badgr doctor --logs ./vllm.log
|
|
@@ -195,6 +205,10 @@ ${chalk.bold('badgr run OPTIONS')}
|
|
|
195
205
|
--no-gpu Run on a CPU-only VM — no GPU is provisioned (conflicts with --gpu/--gpu-memory)
|
|
196
206
|
--tier 1 Managed provider routing (default)
|
|
197
207
|
--tier 2 Marketplace provider routing, lower-cost options
|
|
208
|
+
--smoke Cheapest compatible provider for a local/dev test run — defaults: $0.25
|
|
209
|
+
max cost, 10min max runtime (override with --max-cost/--max-runtime).
|
|
210
|
+
Always: one attempt, mandatory teardown, no --workspace.
|
|
211
|
+
Same as setting BADGR_DEV_CHEAPEST=1. An explicit --tier overrides this.
|
|
198
212
|
--image <image> Docker image (default: python:3.11-slim)
|
|
199
213
|
--env KEY=VALUE Set an environment variable (repeatable)
|
|
200
214
|
--count <n> Number of GPUs (default: 1)
|
|
@@ -270,7 +284,7 @@ async function main() {
|
|
|
270
284
|
case 'detect': return detectCommand(config, rest, chalk);
|
|
271
285
|
case 'run': return runCommand(config, rest, chalk);
|
|
272
286
|
case 'launch': return launchCommand(config, rest, chalk);
|
|
273
|
-
case '
|
|
287
|
+
case 'job': return jobCommand(config, rest, chalk);
|
|
274
288
|
case 'artifacts': return artifactsCommand(config, rest, chalk);
|
|
275
289
|
case 'pull': return pullCommand(config, rest, chalk);
|
|
276
290
|
case 'serve': return serveCommand(config, rest, chalk);
|
|
@@ -280,7 +294,9 @@ async function main() {
|
|
|
280
294
|
case 'restart': return restartCommand(config, rest, chalk);
|
|
281
295
|
case 'rerun': return rerunCommand(config, rest, chalk);
|
|
282
296
|
case 'heartbeat': return heartbeatCommand(config, rest, chalk);
|
|
283
|
-
case '
|
|
297
|
+
case 'diagnose': return diagnoseCommand(config, rest, chalk);
|
|
298
|
+
case 'run-issue': return diagnoseCommand(config, rest, chalk); // alias — matches the /run-issue web flow name
|
|
299
|
+
case 'doctor': return doctorCommand(config, rest, chalk);
|
|
284
300
|
case 'receipts': return receiptsCommand(config, rest, chalk);
|
|
285
301
|
case 'models': return modelsCommand(config, chalk);
|
|
286
302
|
case 'capacity': return capacityCommand(config, rest, chalk);
|
package/src/commands/billing.js
CHANGED
|
@@ -7,7 +7,7 @@ badgr billing — manage your AI Badgr balance
|
|
|
7
7
|
|
|
8
8
|
COMMANDS
|
|
9
9
|
badgr billing status Show current balance
|
|
10
|
-
badgr billing add <amount> Open checkout to add balance (minimum $
|
|
10
|
+
badgr billing add <amount> Open checkout to add balance (minimum $5)
|
|
11
11
|
|
|
12
12
|
EXAMPLES
|
|
13
13
|
badgr billing status
|
|
@@ -49,8 +49,8 @@ async function billingStatus(config, chalk) {
|
|
|
49
49
|
async function billingAdd(config, amount, chalk) {
|
|
50
50
|
requireApiKey(config);
|
|
51
51
|
const amountInt = parseInt(amount, 10);
|
|
52
|
-
if (!amountInt || amountInt <
|
|
53
|
-
console.error(chalk.red(' Minimum top-up is $
|
|
52
|
+
if (!amountInt || amountInt < 5) {
|
|
53
|
+
console.error(chalk.red(' Minimum top-up is $5. Example: badgr billing add 5'));
|
|
54
54
|
process.exit(1);
|
|
55
55
|
}
|
|
56
56
|
|
package/src/commands/connect.js
CHANGED
|
@@ -79,5 +79,8 @@ export async function connectCommand(args, chalk, deps = {}) {
|
|
|
79
79
|
setCredential(provider, key);
|
|
80
80
|
console.log(chalk.green(`\n ✓ ${alreadyConnected ? 'Updated' : 'Connected'} ${provider}`));
|
|
81
81
|
console.log(chalk.dim(` Stored in ${CREDENTIALS_FILE} (owner-only file permissions)`));
|
|
82
|
-
|
|
82
|
+
const usageHint = provider === 'anthropic' ? 'badgr launch claude "<task>"'
|
|
83
|
+
: provider === 'openai' ? 'badgr launch codex "<task>"'
|
|
84
|
+
: `badgr launch cline/claude/codex --provider ${provider} --model <model-id> "<task>"`;
|
|
85
|
+
console.log(chalk.dim(` Used automatically by: ${usageHint}\n`));
|
|
83
86
|
}
|
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { callApi } from '../api.js';
|
|
3
|
+
import { ensureLoggedIn } from '../onboarding.js';
|
|
4
|
+
|
|
5
|
+
const DIAGNOSE_HELP = `
|
|
6
|
+
Paste anything. Badgr detects the input, diagnoses it for free, then
|
|
7
|
+
shows either missing information, a verified template, or a capped
|
|
8
|
+
smoke-test plan. No GPU launches without explicit --approve.
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
badgr diagnose "<input>"
|
|
12
|
+
badgr diagnose "<input>" --approve
|
|
13
|
+
badgr diagnose "<input>" --json
|
|
14
|
+
badgr diagnose <case_id_or_url> --approve Resume an existing case (e.g.
|
|
15
|
+
one shared via a case link)
|
|
16
|
+
|
|
17
|
+
Input (auto-detected):
|
|
18
|
+
GitHub issue URL https://github.com/org/repo/issues/N
|
|
19
|
+
GitHub repo URL https://github.com/org/repo
|
|
20
|
+
Docker image owner/image:tag or image:tag
|
|
21
|
+
Local log / file ./error.log or vllm.log
|
|
22
|
+
ComfyUI workflow workflow.json (JSON file)
|
|
23
|
+
Raw conversation paste Discord / Slack / support thread text
|
|
24
|
+
Existing case repro_xxxxxxxx or https://aibadgr.com/repro/repro_xxxxxxxx
|
|
25
|
+
|
|
26
|
+
Flags:
|
|
27
|
+
--approve Approve the capped smoke test after diagnosis (opens
|
|
28
|
+
browser sign-in automatically if not logged in)
|
|
29
|
+
--docker <image> Force Docker-image intake (override auto-detect)
|
|
30
|
+
--repo <url> Force repository intake (override auto-detect)
|
|
31
|
+
--comfyui <path> Force ComfyUI workflow intake (override auto-detect)
|
|
32
|
+
--github <url> Include a GitHub issue URL found inside pasted text
|
|
33
|
+
as additional context. Opt-in only, never fetched
|
|
34
|
+
automatically (see "Additional context found" below)
|
|
35
|
+
--json Machine-readable JSON output
|
|
36
|
+
--help, -h Show this help
|
|
37
|
+
|
|
38
|
+
Safety: nothing runs from AI-extracted data without --approve.
|
|
39
|
+
No GPU launches without explicit approval and a credit check.
|
|
40
|
+
|
|
41
|
+
Full interactive flow: https://aibadgr.com/run-issue`;
|
|
42
|
+
|
|
43
|
+
function detectInput(raw, flags) {
|
|
44
|
+
if (flags.docker) {
|
|
45
|
+
return {
|
|
46
|
+
kind: 'docker_image',
|
|
47
|
+
label: `Docker image: ${flags.docker}`,
|
|
48
|
+
body: { docker_image: flags.docker },
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
if (flags.repo) {
|
|
52
|
+
return {
|
|
53
|
+
kind: 'repo_url',
|
|
54
|
+
label: `GitHub repository: ${flags.repo}`,
|
|
55
|
+
body: { repo_url: flags.repo },
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
if (flags.comfyui) {
|
|
59
|
+
if (!fs.existsSync(flags.comfyui)) throw new Error(`File not found: ${flags.comfyui}`);
|
|
60
|
+
return {
|
|
61
|
+
kind: 'comfyui_workflow',
|
|
62
|
+
label: `ComfyUI workflow: ${flags.comfyui}`,
|
|
63
|
+
body: { workflow_json: fs.readFileSync(flags.comfyui, 'utf8') },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!raw) return null;
|
|
68
|
+
|
|
69
|
+
// A case_id (bare, or embedded in a /repro/<id> or /run-issue?case_id=<id>
|
|
70
|
+
// link — the two shapes an admin-prepared or self-created case gets
|
|
71
|
+
// shared as) resumes that existing case instead of diagnosing new input.
|
|
72
|
+
const bareCaseMatch = /^repro_[a-zA-Z0-9]+$/.exec(raw.trim());
|
|
73
|
+
const urlCaseMatch = /\/repro\/(repro_[a-zA-Z0-9]+)/.exec(raw) || /[?&]case_id=(repro_[a-zA-Z0-9]+)/.exec(raw);
|
|
74
|
+
const caseId = bareCaseMatch?.[0] || urlCaseMatch?.[1];
|
|
75
|
+
if (caseId) {
|
|
76
|
+
return { kind: 'existing_case', label: `Existing case: ${caseId}`, caseId };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (/^https?:\/\/(www\.)?github\.com\/[^/]+\/[^/]+\/issues\/\d/.test(raw)) {
|
|
80
|
+
return {
|
|
81
|
+
kind: 'github_issue',
|
|
82
|
+
label: `GitHub issue: ${raw}`,
|
|
83
|
+
body: { github_url: raw },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (/^https?:\/\/(www\.)?github\.com\//.test(raw)) {
|
|
88
|
+
return {
|
|
89
|
+
kind: 'repo_url',
|
|
90
|
+
label: `GitHub repository: ${raw}`,
|
|
91
|
+
body: { repo_url: raw },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (fs.existsSync(raw)) {
|
|
96
|
+
const content = fs.readFileSync(raw, 'utf8');
|
|
97
|
+
if (raw.endsWith('.json') || raw.endsWith('.JSON')) {
|
|
98
|
+
let parsed;
|
|
99
|
+
try { parsed = JSON.parse(content); } catch { /* not valid JSON */ }
|
|
100
|
+
if (parsed && typeof parsed === 'object' && (parsed.nodes || parsed['1'] || parsed['0'])) {
|
|
101
|
+
return {
|
|
102
|
+
kind: 'comfyui_workflow',
|
|
103
|
+
label: `ComfyUI workflow: ${raw}`,
|
|
104
|
+
body: { workflow_json: content },
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
kind: 'log_file',
|
|
110
|
+
label: `Log / error file: ${raw}`,
|
|
111
|
+
body: { text: content.slice(0, 50_000) },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (
|
|
116
|
+
!raw.startsWith('http') &&
|
|
117
|
+
/^[a-z0-9][a-z0-9._\-]*(?:\/[a-z0-9._\-]+)*(?::[a-zA-Z0-9._\-]+)?$/.test(raw) &&
|
|
118
|
+
raw.length < 200
|
|
119
|
+
) {
|
|
120
|
+
return {
|
|
121
|
+
kind: 'docker_image',
|
|
122
|
+
label: `Docker image: ${raw}`,
|
|
123
|
+
body: { docker_image: raw },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
kind: 'text',
|
|
129
|
+
label: 'Text / conversation',
|
|
130
|
+
body: { text: raw.slice(0, 50_000) },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _printResult(result, chalk) {
|
|
135
|
+
const { extraction: e, static_incompatibilities, multi_gpu_detected, large_download, github_issue, detected_github_url } = result;
|
|
136
|
+
|
|
137
|
+
if (github_issue) {
|
|
138
|
+
console.log(` ${chalk.bold('Issue:')} ${github_issue.title} ${chalk.dim(`[${github_issue.state}]`)}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The conversation stays primary — a GitHub URL found inside pasted text
|
|
142
|
+
// is only ever offered here, never fetched automatically.
|
|
143
|
+
if (detected_github_url) {
|
|
144
|
+
console.log();
|
|
145
|
+
console.log(` ${chalk.bold('Additional context found:')} ${detected_github_url}`);
|
|
146
|
+
console.log(` ${chalk.dim(`Include it: badgr diagnose "<input>" --github ${detected_github_url}`)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const wt = e.workload_type && e.workload_type !== 'unknown' ? e.workload_type : null;
|
|
150
|
+
const conf = e.confidence ? ` ${chalk.dim(`(${Math.round(e.confidence * 100)}% confidence)`)}` : '';
|
|
151
|
+
if (wt) console.log(` ${chalk.bold('Workload:')} ${wt}${conf}`);
|
|
152
|
+
if (e.summary) console.log(` ${chalk.bold('Summary:')} ${e.summary}`);
|
|
153
|
+
console.log();
|
|
154
|
+
|
|
155
|
+
if (e.docker_images?.length) {
|
|
156
|
+
console.log(` ${chalk.bold('Image:')} ${e.docker_images[0]}${e.docker_images.length > 1 ? chalk.dim(` +${e.docker_images.length - 1} more`) : ''}`);
|
|
157
|
+
}
|
|
158
|
+
if (e.models?.length) {
|
|
159
|
+
console.log(` ${chalk.bold('Model:')} ${e.models[0]}${e.models.length > 1 ? chalk.dim(` +${e.models.length - 1} more`) : ''}`);
|
|
160
|
+
}
|
|
161
|
+
if (e.commands?.length) {
|
|
162
|
+
console.log(` ${chalk.bold('Command:')} ${e.commands[0]}`);
|
|
163
|
+
}
|
|
164
|
+
if (e.errors?.length) {
|
|
165
|
+
console.log(` ${chalk.bold('Errors:')}`);
|
|
166
|
+
for (const err of e.errors.slice(0, 3)) console.log(` ${chalk.red('✗')} ${err}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (static_incompatibilities?.length) {
|
|
170
|
+
console.log();
|
|
171
|
+
console.log(` ${chalk.bold('Diagnosis:')} ${chalk.yellow('Static incompatibility — no GPU needed')}`);
|
|
172
|
+
for (const inc of static_incompatibilities) console.log(` ${chalk.yellow('!')} ${inc}`);
|
|
173
|
+
} else if (e.missing_information?.length) {
|
|
174
|
+
console.log();
|
|
175
|
+
console.log(` ${chalk.bold('Diagnosis:')} Missing information`);
|
|
176
|
+
for (const m of e.missing_information) console.log(` ${chalk.dim('?')} ${m}`);
|
|
177
|
+
} else if (wt) {
|
|
178
|
+
console.log();
|
|
179
|
+
console.log(` ${chalk.bold('Diagnosis:')} Workload detected — ready for capped test`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (multi_gpu_detected) {
|
|
183
|
+
console.log(` ${chalk.yellow('!')} Multi-GPU detected: ${multi_gpu_detected}`);
|
|
184
|
+
console.log(` ${chalk.dim('Capped test will use a single GPU only.')}`);
|
|
185
|
+
}
|
|
186
|
+
if (large_download) {
|
|
187
|
+
console.log(` ${chalk.yellow('!')} Large model download: ~${large_download.estimated_gb} GB`);
|
|
188
|
+
console.log(` ${chalk.dim('Funding approval required before test launches.')}`);
|
|
189
|
+
}
|
|
190
|
+
if (result.secret_types_detected?.length) {
|
|
191
|
+
console.log(` ${chalk.dim(`Secrets redacted before analysis: ${result.secret_types_detected.join(', ')}`)}`);
|
|
192
|
+
}
|
|
193
|
+
if (!result.ai_available) {
|
|
194
|
+
console.log(` ${chalk.dim('(AI extraction unavailable — pattern matching only)')}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
console.log();
|
|
198
|
+
if (static_incompatibilities?.length) {
|
|
199
|
+
console.log(` ${chalk.bold('Recommended action:')} Fix the incompatibility listed above — no GPU test needed`);
|
|
200
|
+
} else if (e.missing_information?.length) {
|
|
201
|
+
console.log(` ${chalk.bold('Recommended action:')} Provide the missing details above, then re-run`);
|
|
202
|
+
} else {
|
|
203
|
+
console.log(` ${chalk.bold('Recommended action:')} Run ${chalk.cyan('badgr diagnose "<input>" --approve')} to launch a capped test`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function _doApprove(config, result, chalk) {
|
|
208
|
+
// Diagnosis and case creation stay anonymous and free — login is only
|
|
209
|
+
// required once we know a real GPU test needs approving (below).
|
|
210
|
+
const e = result.extraction;
|
|
211
|
+
const confirmBody = {
|
|
212
|
+
workload_type: e.workload_type || 'generic',
|
|
213
|
+
docker_image: e.docker_images?.[0] || null,
|
|
214
|
+
model_id: e.models?.[0] || null,
|
|
215
|
+
launch_command: e.commands?.[0] || null,
|
|
216
|
+
error_log: null,
|
|
217
|
+
expected_result: e.expected_result || null,
|
|
218
|
+
environment_variable_names: e.environment_variable_names || [],
|
|
219
|
+
gpu_requirements: Array.isArray(e.gpu_requirements)
|
|
220
|
+
? e.gpu_requirements.join(', ')
|
|
221
|
+
: e.gpu_requirements || null,
|
|
222
|
+
source_summary: e.summary || null,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
let caseData;
|
|
226
|
+
try {
|
|
227
|
+
console.log(chalk.dim(' Creating case...'));
|
|
228
|
+
caseData = await callApi('/run-issue/confirm', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
apiKey: config.apiKey || '',
|
|
231
|
+
baseUrl: config.baseUrl,
|
|
232
|
+
body: confirmBody,
|
|
233
|
+
timeoutMs: 20_000,
|
|
234
|
+
});
|
|
235
|
+
} catch (err) {
|
|
236
|
+
console.error(chalk.red(`\n ✗ Could not create case: ${err.message}\n`));
|
|
237
|
+
process.exitCode = 1;
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const { case_id: caseId, status, test_plan: plan, missing_information: missing } = caseData;
|
|
242
|
+
|
|
243
|
+
if (status === 'incompatible') {
|
|
244
|
+
console.log(chalk.yellow('\n Static incompatibility — no GPU test needed.'));
|
|
245
|
+
console.log();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (status === 'missing_information') {
|
|
249
|
+
console.log(chalk.yellow('\n Cannot approve — missing information:'));
|
|
250
|
+
for (const m of (missing || [])) console.log(` ? ${m}`);
|
|
251
|
+
console.log();
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (status === 'template_matched') {
|
|
255
|
+
console.log(chalk.green('\n Matched a verified template — no new GPU test needed.'));
|
|
256
|
+
if (plan?.final_command) {
|
|
257
|
+
console.log();
|
|
258
|
+
console.log(` ${chalk.bold('Verified command:')}`);
|
|
259
|
+
console.log(` ${chalk.cyan(plan.final_command)}`);
|
|
260
|
+
}
|
|
261
|
+
console.log();
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (plan) {
|
|
266
|
+
console.log();
|
|
267
|
+
if (plan.gpu_type) console.log(` ${chalk.bold('GPU:')} ${plan.gpu_type}`);
|
|
268
|
+
if (plan.estimated_cost) console.log(` ${chalk.bold('Maximum cost:')} $${Number(plan.estimated_cost).toFixed(2)}`);
|
|
269
|
+
if (plan.max_runtime_min) console.log(` ${chalk.bold('Max runtime:')} ${plan.max_runtime_min} min`);
|
|
270
|
+
if (plan.requires_reduced_test) console.log(` ${chalk.yellow('!')} Multi-GPU detected — test uses a single GPU`);
|
|
271
|
+
if (plan.requires_funding_approval) console.log(` ${chalk.yellow('!')} Large download — funding required`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
await _approveAndRun(config, caseId, plan, chalk);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Claim → approve → run for a case that already exists (just created via
|
|
278
|
+
// /confirm above, or resumed via an existing_case input). Auth is required
|
|
279
|
+
// from here on — diagnosis and case creation stay anonymous and free, but a
|
|
280
|
+
// real GPU test needs a signed-in account. The CLI never grants a free-run
|
|
281
|
+
// entitlement (web admin only, see grant_free_run in run_issue_routes.py);
|
|
282
|
+
// it only redeems one a case already carries, same as normal billing.
|
|
283
|
+
async function _approveAndRun(config, caseId, plan, chalk) {
|
|
284
|
+
const printResumeHint = () =>
|
|
285
|
+
console.error(chalk.dim(` Resume this case: https://aibadgr.com/run-issue?case_id=${caseId}\n`));
|
|
286
|
+
|
|
287
|
+
let authConfig = config;
|
|
288
|
+
if (!authConfig.apiKey) {
|
|
289
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
290
|
+
console.error(chalk.red('\n ✗ Sign in required to approve a GPU test. Run: badgr login\n'));
|
|
291
|
+
printResumeHint();
|
|
292
|
+
process.exitCode = 1;
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
console.log(chalk.yellow('\n Sign in to continue. Opening browser...'));
|
|
296
|
+
try {
|
|
297
|
+
authConfig = await ensureLoggedIn(authConfig, chalk);
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.error(chalk.red(`\n ✗ Sign-in failed: ${err.message}\n`));
|
|
300
|
+
printResumeHint();
|
|
301
|
+
process.exitCode = 1;
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
await callApi(`/run-issue/cases/${caseId}/claim`, {
|
|
308
|
+
method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 10_000,
|
|
309
|
+
});
|
|
310
|
+
} catch { /* already claimed — continue */ }
|
|
311
|
+
|
|
312
|
+
let approved;
|
|
313
|
+
try {
|
|
314
|
+
approved = await callApi(`/run-issue/cases/${caseId}/approve`, {
|
|
315
|
+
method: 'POST',
|
|
316
|
+
apiKey: authConfig.apiKey,
|
|
317
|
+
baseUrl: authConfig.baseUrl,
|
|
318
|
+
body: {
|
|
319
|
+
confirmed: true,
|
|
320
|
+
funding_approved: Boolean(plan?.requires_funding_approval),
|
|
321
|
+
reduced_test_confirmed: Boolean(plan?.requires_reduced_test),
|
|
322
|
+
},
|
|
323
|
+
timeoutMs: 15_000,
|
|
324
|
+
});
|
|
325
|
+
} catch (err) {
|
|
326
|
+
if (err.httpStatus === 402 || err.message?.includes('402')) {
|
|
327
|
+
console.error(chalk.yellow('\n ✗ Insufficient credits. Run: badgr billing\n'));
|
|
328
|
+
} else {
|
|
329
|
+
console.error(chalk.red(`\n ✗ Approve failed: ${err.message}\n`));
|
|
330
|
+
}
|
|
331
|
+
process.exitCode = 1;
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
console.log(chalk.dim('\n Launching capped smoke test...'));
|
|
336
|
+
let ran;
|
|
337
|
+
try {
|
|
338
|
+
ran = await callApi(`/run-issue/cases/${caseId}/run`, {
|
|
339
|
+
method: 'POST', apiKey: authConfig.apiKey, baseUrl: authConfig.baseUrl, timeoutMs: 30_000,
|
|
340
|
+
});
|
|
341
|
+
} catch (err) {
|
|
342
|
+
console.error(chalk.red(`\n ✗ Launch failed: ${err.message}\n`));
|
|
343
|
+
process.exitCode = 1;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
console.log();
|
|
348
|
+
console.log(chalk.green(' Smoke test launched.'));
|
|
349
|
+
console.log(` ${chalk.bold('Case:')} ${caseId}`);
|
|
350
|
+
if (ran?.job_id) console.log(` ${chalk.bold('Job:')} ${ran.job_id}`);
|
|
351
|
+
if (ran?.free_run_used) console.log(` ${chalk.bold('Billing:')} Free run — no charge`);
|
|
352
|
+
console.log(` ${chalk.bold('Evidence:')} https://aibadgr.com/repro/${caseId}`);
|
|
353
|
+
console.log();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Resume an existing case (bare case_id or a shared /repro or /run-issue
|
|
357
|
+
// link) instead of diagnosing new input. No /confirm — the case already
|
|
358
|
+
// exists; this only reads its current state and, with --approve, redeems
|
|
359
|
+
// whatever entitlement/billing state it already carries.
|
|
360
|
+
async function _diagnoseExistingCase(config, caseId, flags, chalk) {
|
|
361
|
+
let caseData;
|
|
362
|
+
try {
|
|
363
|
+
caseData = await callApi(`/run-issue/cases/${caseId}`, {
|
|
364
|
+
apiKey: config.apiKey || '', baseUrl: config.baseUrl, timeoutMs: 15_000,
|
|
365
|
+
});
|
|
366
|
+
} catch (err) {
|
|
367
|
+
console.error(chalk.red(`\n ✗ Could not load case ${caseId}: ${err.message}\n`));
|
|
368
|
+
process.exitCode = 1;
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (flags.json) {
|
|
373
|
+
console.log(JSON.stringify(caseData, null, 2));
|
|
374
|
+
if (flags.approve) await _approveAndRun(config, caseId, caseData.test_plan, chalk);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
console.log();
|
|
379
|
+
console.log(` ${chalk.bold('Case:')} ${caseId}`);
|
|
380
|
+
console.log(` ${chalk.bold('Status:')} ${caseData.status}`);
|
|
381
|
+
if (caseData.workload_type) console.log(` ${chalk.bold('Workload:')} ${caseData.workload_type}`);
|
|
382
|
+
if (caseData.known_command) console.log(` ${chalk.bold('Command:')} ${caseData.known_command}`);
|
|
383
|
+
if ((caseData.missing_information || []).length) {
|
|
384
|
+
console.log();
|
|
385
|
+
console.log(` ${chalk.bold('Missing information:')}`);
|
|
386
|
+
for (const m of caseData.missing_information) console.log(` ? ${m}`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
console.log();
|
|
390
|
+
if (caseData.free_verification_consumed) {
|
|
391
|
+
console.log(` ${chalk.dim('This case\'s free run has already been used — further verification is normal billing.')}`);
|
|
392
|
+
} else if (caseData.free_verification_available) {
|
|
393
|
+
console.log(` ${chalk.green(`1 free verification job available — up to $${(caseData.free_verification_max_cost_usd ?? 5).toFixed(0)}`)}`);
|
|
394
|
+
} else if (caseData.test_plan) {
|
|
395
|
+
console.log(` ${chalk.dim(`Verification available — max $${Number(caseData.test_plan.max_cost_usd ?? 0).toFixed(2)}`)}`);
|
|
396
|
+
}
|
|
397
|
+
console.log();
|
|
398
|
+
|
|
399
|
+
if (flags.approve) {
|
|
400
|
+
await _approveAndRun(config, caseId, caseData.test_plan, chalk);
|
|
401
|
+
} else {
|
|
402
|
+
console.log(` ${chalk.bold('Resume:')} badgr diagnose ${caseId} --approve`);
|
|
403
|
+
console.log();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export async function diagnoseCommand(config, args, chalk) {
|
|
408
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
409
|
+
console.log(DIAGNOSE_HELP);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const flags = {};
|
|
414
|
+
let positional = null;
|
|
415
|
+
let i = 0;
|
|
416
|
+
while (i < args.length) {
|
|
417
|
+
const a = args[i];
|
|
418
|
+
if (a === '--docker') { flags.docker = args[++i]; i++; continue; }
|
|
419
|
+
if (a === '--repo') { flags.repo = args[++i]; i++; continue; }
|
|
420
|
+
if (a === '--comfyui') { flags.comfyui = args[++i]; i++; continue; }
|
|
421
|
+
if (a === '--github') { flags.github = args[++i]; i++; continue; }
|
|
422
|
+
if (a === '--approve') { flags.approve = true; i++; continue; }
|
|
423
|
+
if (a === '--json') { flags.json = true; i++; continue; }
|
|
424
|
+
if (!a.startsWith('-') && positional === null) positional = a;
|
|
425
|
+
i++;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!positional && !flags.docker && !flags.repo && !flags.comfyui) {
|
|
429
|
+
console.log(DIAGNOSE_HELP);
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
let detected;
|
|
434
|
+
try {
|
|
435
|
+
detected = detectInput(positional, flags);
|
|
436
|
+
} catch (err) {
|
|
437
|
+
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
438
|
+
process.exitCode = 1;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (!detected) {
|
|
443
|
+
console.log(DIAGNOSE_HELP);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
if (detected.kind === 'existing_case') {
|
|
448
|
+
await _diagnoseExistingCase(config, detected.caseId, flags, chalk);
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// --github opts into fetching a GitHub issue URL found inside pasted text
|
|
453
|
+
// (see the "Additional context found" notice) — never fetched by default.
|
|
454
|
+
if (flags.github) detected.body.github_url = flags.github;
|
|
455
|
+
|
|
456
|
+
if (!flags.json) {
|
|
457
|
+
console.log();
|
|
458
|
+
console.log(` ${chalk.bold('Detected:')} ${detected.label}`);
|
|
459
|
+
console.log(chalk.dim(' Analysing...'));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
let result;
|
|
463
|
+
try {
|
|
464
|
+
result = await callApi('/run-issue/extract', {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
apiKey: config.apiKey || '',
|
|
467
|
+
baseUrl: config.baseUrl,
|
|
468
|
+
body: detected.body,
|
|
469
|
+
timeoutMs: 120_000,
|
|
470
|
+
});
|
|
471
|
+
} catch (err) {
|
|
472
|
+
console.error(chalk.red(`\n ✗ ${err.message}\n`));
|
|
473
|
+
process.exitCode = 1;
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (flags.json) {
|
|
478
|
+
console.log(JSON.stringify({ detected: detected.kind, ...result }, null, 2));
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
console.log();
|
|
483
|
+
_printResult(result, chalk);
|
|
484
|
+
|
|
485
|
+
console.log();
|
|
486
|
+
console.log(chalk.dim(' Free diagnosis. No GPU was provisioned.'));
|
|
487
|
+
console.log(chalk.dim(' Full interactive flow: https://aibadgr.com/run-issue'));
|
|
488
|
+
console.log();
|
|
489
|
+
|
|
490
|
+
if (flags.approve) {
|
|
491
|
+
await _doApprove(config, result, chalk);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { callApi } from '../api.js';
|
|
2
|
+
import { requireApiKey } from '../config.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* badgr job <agent> "<instruction>" --check "npm test"
|
|
6
|
+
*
|
|
7
|
+
* Submits a bounded coding-agent job via POST /v1/jobs (type: "agent"),
|
|
8
|
+
* which creates a Job record and runs it on a GPU/CPU VM.
|
|
9
|
+
* All three interfaces (website, CLI, POST /v1/jobs) share the same
|
|
10
|
+
* execution path on the backend (backend/jobs_routes.py).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* badgr job cline "Fix the checkout bug" --check "npm test"
|
|
14
|
+
* badgr job claude "Add pagination" --check "npm run test:e2e" --max-cost 3
|
|
15
|
+
* badgr job codex "Refactor auth" --check "pytest tests/" --repo https://github.com/org/repo
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const _BOOL_FLAGS = { '--dry-run': 'dryRun', '--detach': 'detach' };
|
|
19
|
+
const _VALUE_FLAGS = {
|
|
20
|
+
'--check': 'check', '--eval': 'check', '--eval-command': 'check',
|
|
21
|
+
'--repo': 'repository', '--repository': 'repository',
|
|
22
|
+
'--ref': 'ref',
|
|
23
|
+
'--agent': 'agent',
|
|
24
|
+
'--provider': 'provider',
|
|
25
|
+
'--model': 'model',
|
|
26
|
+
'--max-cost': 'maxCost',
|
|
27
|
+
'--max-runtime': 'maxRuntime',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// Scan the arg list, pull out known flags wherever they appear, and collect
|
|
31
|
+
// the remaining tokens as positionals. This lets flags appear anywhere:
|
|
32
|
+
// badgr job cline "Fix it" --check "npm test"
|
|
33
|
+
// badgr job "Fix it" --check "npm test" --agent cline
|
|
34
|
+
function parseJobArgs(rawArgs) {
|
|
35
|
+
const flags = {};
|
|
36
|
+
const positional = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < rawArgs.length) {
|
|
39
|
+
const tok = rawArgs[i];
|
|
40
|
+
if (tok in _BOOL_FLAGS) { flags[_BOOL_FLAGS[tok]] = true; i += 1; continue; }
|
|
41
|
+
if (tok in _VALUE_FLAGS) { flags[_VALUE_FLAGS[tok]] = rawArgs[i + 1]; i += 2; continue; }
|
|
42
|
+
positional.push(tok);
|
|
43
|
+
i += 1;
|
|
44
|
+
}
|
|
45
|
+
return { flags, positional };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Providers the agent Job type accepts (backend jobs_routes._VALID_AGENT_PROVIDERS).
|
|
49
|
+
// The BYOK/OpenAI-compatible providers `badgr launch --provider` supports
|
|
50
|
+
// (openrouter, deepseek, glm, custom) are NOT among them: the Jobs API
|
|
51
|
+
// resolves credentials server-side from stored provider credentials and has
|
|
52
|
+
// nowhere to put a custom base URL, so those must be rejected in the CLI
|
|
53
|
+
// rather than sent on to a 400 from the server.
|
|
54
|
+
export const JOB_API_PROVIDERS = ['badgr', 'openai', 'anthropic'];
|
|
55
|
+
|
|
56
|
+
export const JOB_AGENTS = ['cline', 'claude', 'claude-code', 'codex', 'playwright'];
|
|
57
|
+
|
|
58
|
+
export async function jobCommand(config, args, chalk) {
|
|
59
|
+
// Support both:
|
|
60
|
+
// badgr job <agent> "<instruction>" --check "..."
|
|
61
|
+
// badgr job "<instruction>" --check "..." --agent cline (fallback)
|
|
62
|
+
const { flags, positional } = parseJobArgs(args);
|
|
63
|
+
|
|
64
|
+
let agentName = null;
|
|
65
|
+
let instructionText = null;
|
|
66
|
+
|
|
67
|
+
const knownAgents = new Set(JOB_AGENTS);
|
|
68
|
+
|
|
69
|
+
if (positional.length >= 2 && knownAgents.has(positional[0])) {
|
|
70
|
+
agentName = positional[0];
|
|
71
|
+
instructionText = positional.slice(1).join(' ').trim();
|
|
72
|
+
} else if (positional.length >= 1) {
|
|
73
|
+
instructionText = positional.join(' ').trim();
|
|
74
|
+
agentName = flags.agent ?? 'cline';
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!instructionText || !agentName) {
|
|
78
|
+
console.error(chalk.red('\nUsage: badgr job <agent> "<instruction>" --check "<command>"\n'));
|
|
79
|
+
console.error(chalk.dim(' Agents: cline (default), claude-code, codex, playwright'));
|
|
80
|
+
console.error(chalk.dim(' Example: badgr job cline "Fix the checkout bug" --check "npm test"'));
|
|
81
|
+
console.error(chalk.dim(' Example: badgr job claude-code "Add pagination" --check "pytest tests/" --max-cost 3\n'));
|
|
82
|
+
process.exitCode = 1;
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!flags.check) {
|
|
87
|
+
console.error(chalk.red('\n ✗ --check <command> is required — it verifies the job succeeded.\n'));
|
|
88
|
+
console.error(chalk.dim(' Example: badgr job cline "Fix the bug" --check "npm test"\n'));
|
|
89
|
+
process.exitCode = 1;
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return runJob(config, {
|
|
94
|
+
agent: agentName,
|
|
95
|
+
instruction: instructionText,
|
|
96
|
+
check: flags.check,
|
|
97
|
+
provider: flags.provider,
|
|
98
|
+
model: flags.model,
|
|
99
|
+
repository: flags.repository,
|
|
100
|
+
ref: flags.ref,
|
|
101
|
+
maxCost: flags.maxCost,
|
|
102
|
+
maxRuntime: flags.maxRuntime,
|
|
103
|
+
dryRun: flags.dryRun,
|
|
104
|
+
detach: flags.detach,
|
|
105
|
+
}, chalk);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Submit one agent job to POST /v1/jobs and (unless detached) poll it to a
|
|
110
|
+
* terminal state. Takes an already-resolved options object rather than argv
|
|
111
|
+
* so callers that have parsed their own flags — `badgr launch <agent>
|
|
112
|
+
* --eval-command ...` — can reuse this path directly instead of
|
|
113
|
+
* re-serializing their flags back into an argv for jobCommand to reparse.
|
|
114
|
+
*/
|
|
115
|
+
export async function runJob(config, opts, chalk) {
|
|
116
|
+
const { agent: agentName, instruction: instructionText, check } = opts;
|
|
117
|
+
|
|
118
|
+
// Require API key — jobs are tracked under the user account.
|
|
119
|
+
const apiKey = requireApiKey(config, chalk);
|
|
120
|
+
if (!apiKey) return;
|
|
121
|
+
|
|
122
|
+
const provider = opts.provider ?? null;
|
|
123
|
+
const model = opts.model ?? null;
|
|
124
|
+
const repository = opts.repository ?? '.';
|
|
125
|
+
const ref = opts.ref ?? null;
|
|
126
|
+
const maxCostUsd = opts.maxCost != null ? Number(opts.maxCost) : 2.0;
|
|
127
|
+
const maxRuntimeSeconds = opts.maxRuntime != null ? Number(opts.maxRuntime) : 1800;
|
|
128
|
+
|
|
129
|
+
if (provider && !JOB_API_PROVIDERS.includes(provider)) {
|
|
130
|
+
console.error(chalk.red(`\n ✗ The Jobs API does not accept --provider ${provider}.`));
|
|
131
|
+
console.error(chalk.dim(` Supported: ${JOB_API_PROVIDERS.join(', ')}`));
|
|
132
|
+
console.error(chalk.dim(' BYOK providers (openrouter, deepseek, glm, custom) work with'));
|
|
133
|
+
console.error(chalk.dim(' badgr launch without --eval-command.\n'));
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Print plan.
|
|
139
|
+
console.log('');
|
|
140
|
+
console.log(chalk.bold(` Job: ${instructionText}`));
|
|
141
|
+
console.log(chalk.dim(` Agent: ${agentName}${provider ? ` via ${provider}` : ''}${model ? ` / ${model}` : ''}`));
|
|
142
|
+
console.log(chalk.dim(` Check: ${check}`));
|
|
143
|
+
console.log(chalk.dim(` Max: $${maxCostUsd.toFixed(2)} / ${Math.round(maxRuntimeSeconds / 60)} min`));
|
|
144
|
+
console.log('');
|
|
145
|
+
|
|
146
|
+
if (opts.dryRun) {
|
|
147
|
+
console.log(chalk.yellow(' (dry run — no job submitted)\n'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Submit job.
|
|
152
|
+
let job;
|
|
153
|
+
try {
|
|
154
|
+
job = await callApi('/v1/jobs', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
apiKey,
|
|
157
|
+
baseUrl: config.baseUrl,
|
|
158
|
+
body: {
|
|
159
|
+
type: 'agent',
|
|
160
|
+
input: {
|
|
161
|
+
repository,
|
|
162
|
+
ref,
|
|
163
|
+
agent: agentName,
|
|
164
|
+
provider,
|
|
165
|
+
model,
|
|
166
|
+
instruction: instructionText,
|
|
167
|
+
check,
|
|
168
|
+
},
|
|
169
|
+
policy: {
|
|
170
|
+
max_cost: maxCostUsd,
|
|
171
|
+
max_runtime_minutes: Math.round(maxRuntimeSeconds / 60),
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
} catch (err) {
|
|
176
|
+
console.error(chalk.red(`\n ✗ Could not submit job: ${err.message}\n`));
|
|
177
|
+
process.exitCode = 1;
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (job?.detail || job?.error) {
|
|
182
|
+
console.error(chalk.red(`\n ✗ ${job.detail ?? job.error}\n`));
|
|
183
|
+
process.exitCode = 1;
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const jobId = job?.job_id;
|
|
188
|
+
if (!jobId) {
|
|
189
|
+
console.error(chalk.red('\n ✗ Unexpected response from server.\n'));
|
|
190
|
+
process.exitCode = 1;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
console.log(chalk.green(` ✓ Job submitted`));
|
|
195
|
+
console.log(chalk.dim(` ID: ${jobId}`));
|
|
196
|
+
|
|
197
|
+
if (opts.detach) {
|
|
198
|
+
console.log(chalk.dim(` Status: badgr status (or GET /v1/jobs/${jobId})\n`));
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Poll until terminal.
|
|
203
|
+
console.log(chalk.dim(' Waiting for job to complete…\n'));
|
|
204
|
+
const pollMs = 10_000;
|
|
205
|
+
const maxWaitMs = (maxRuntimeSeconds + 60) * 1000;
|
|
206
|
+
const startMs = Date.now();
|
|
207
|
+
|
|
208
|
+
while (true) {
|
|
209
|
+
await new Promise(r => setTimeout(r, pollMs));
|
|
210
|
+
if (Date.now() - startMs > maxWaitMs) {
|
|
211
|
+
console.error(chalk.yellow(`\n ⚠ Timed out waiting. Job ${jobId} is still running.\n`));
|
|
212
|
+
console.log(chalk.dim(` Check: badgr status (or GET /v1/jobs/${jobId})\n`));
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
let latest;
|
|
216
|
+
try {
|
|
217
|
+
latest = await callApi(`/v1/jobs/${jobId}`, { apiKey, baseUrl: config.baseUrl });
|
|
218
|
+
} catch {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
if (!latest?.job_id) continue;
|
|
222
|
+
if (latest.status === 'running' && latest.stage) {
|
|
223
|
+
process.stdout.write(chalk.dim(`\r ${latest.stage}…`));
|
|
224
|
+
}
|
|
225
|
+
if (latest.status === 'completed' || latest.status === 'failed' || latest.status === 'canceled') {
|
|
226
|
+
console.log('');
|
|
227
|
+
if (latest.status === 'completed') {
|
|
228
|
+
console.log(chalk.green(`\n ✓ Job succeeded`));
|
|
229
|
+
if (latest.output?.exit_code != null) {
|
|
230
|
+
console.log(chalk.green(` Check: ${latest.output.exit_code === 0 ? 'passed' : 'failed'}`));
|
|
231
|
+
}
|
|
232
|
+
} else if (latest.status === 'failed') {
|
|
233
|
+
console.error(chalk.red(`\n ✗ Job failed`));
|
|
234
|
+
if (latest.error?.message) console.error(chalk.dim(` ${latest.error.message}`));
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
} else {
|
|
237
|
+
console.log(chalk.yellow(`\n Job ${latest.status}`));
|
|
238
|
+
}
|
|
239
|
+
if (latest.charged_usd != null) {
|
|
240
|
+
console.log(chalk.dim(` Cost: $${latest.charged_usd.toFixed(4)}`));
|
|
241
|
+
}
|
|
242
|
+
console.log('');
|
|
243
|
+
break;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
package/src/commands/launch.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { runCommand, parseRunArgs } from './run.js';
|
|
2
|
-
import {
|
|
2
|
+
import { runJob } from './job.js';
|
|
3
|
+
import { getCredential, setCredential, PROVIDER_ENV_KEYS, MODEL_PROVIDERS, KNOWN_MODEL_PROVIDERS, ENV_KEYS_FOR_API_KIND } from '../credentials.js';
|
|
3
4
|
import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
|
|
4
5
|
|
|
5
6
|
// Phase 1 supports these coding/testing workloads via shorthand. Do not add
|
|
@@ -43,8 +44,8 @@ import { VM_CLASSES, LAUNCH_VM_SIZES, vmClassForWorkload } from '../spec.js';
|
|
|
43
44
|
// images/badgr-job-runner/entrypoint.py, which subprocess.run executes
|
|
44
45
|
// directly with shell=False — no shlex.split() involved for this path).
|
|
45
46
|
const AGENT_WORKLOADS = {
|
|
46
|
-
cline: { image: process.env.BADGR_AGENT_IMAGE_CLINE || 'ghcr.io/michaelmanly/badgr-agent-cline:latest', buildCmd: task => ['badgr-cline-run', task],
|
|
47
|
-
claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['claude
|
|
47
|
+
cline: { image: process.env.BADGR_AGENT_IMAGE_CLINE || 'ghcr.io/michaelmanly/badgr-agent-cline:latest', buildCmd: task => ['badgr-cline-run', task], provider: null },
|
|
48
|
+
claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['badgr-claude-run', task], provider: 'anthropic' },
|
|
48
49
|
codex: { image: process.env.BADGR_AGENT_IMAGE_CODEX || 'ghcr.io/michaelmanly/badgr-agent-codex:latest', buildCmd: task => ['badgr-codex-run', task], provider: 'openai' },
|
|
49
50
|
};
|
|
50
51
|
|
|
@@ -100,9 +101,25 @@ const _VALUE_FLAGS = {
|
|
|
100
101
|
'--max-runtime': 'maxRuntime', '--max-cost': 'maxCost', '--save': 'save',
|
|
101
102
|
'--workspace': 'workspace', '--output': 'output', '--checkpoint': 'checkpoint',
|
|
102
103
|
'--resume-cmd': 'resumeCmd', '--size': 'size',
|
|
104
|
+
// BYOK / OpenAI-compatible model selection — `badgr launch cline` only,
|
|
105
|
+
// see resolveModelProviderEnv below.
|
|
106
|
+
'--provider': 'provider', '--model': 'model', '--base-url': 'baseUrl',
|
|
107
|
+
// When present, routes to POST /v1/jobs (type: agent) instead of the
|
|
108
|
+
// plain badgr-launch GPU/CPU run path.
|
|
109
|
+
'--eval-command': 'evalCommand', '--eval': 'evalCommand',
|
|
103
110
|
};
|
|
104
111
|
const _REPEATABLE_FLAGS = { '--env': 'env', '--artifacts': 'artifacts' };
|
|
105
112
|
|
|
113
|
+
// `badgr launch --eval-command` hands the run to the agent Job type
|
|
114
|
+
// (POST /v1/jobs, type: agent), which provisions and configures the VM
|
|
115
|
+
// server-side. These VM-shaping flags have no equivalent there, so they are
|
|
116
|
+
// called out instead of being silently dropped.
|
|
117
|
+
const JOB_API_IGNORED_FLAGS = [
|
|
118
|
+
['--base-url', 'baseUrl'], ['--env', 'env'], ['--artifacts', 'artifacts'],
|
|
119
|
+
['--workspace', 'workspace'], ['--output', 'output'], ['--size', 'size'],
|
|
120
|
+
['--image', 'image'], ['--region', 'region'], ['--tier', 'tier'],
|
|
121
|
+
];
|
|
122
|
+
|
|
106
123
|
const _PASSTHROUGH_FLAGS = [
|
|
107
124
|
['--image', 'image'], ['--count', 'count'], ['--region', 'region'],
|
|
108
125
|
['--tier', 'tier'], ['--max-price', 'maxPrice'], ['--name', 'name'],
|
|
@@ -229,28 +246,32 @@ function resolveVmSize(flags, workloadName, chalk) {
|
|
|
229
246
|
return true;
|
|
230
247
|
}
|
|
231
248
|
|
|
232
|
-
async function resolveMissingCredential(provider, chalk) {
|
|
233
|
-
const
|
|
249
|
+
async function resolveMissingCredential(provider, chalk, label) {
|
|
250
|
+
const displayLabel = label ?? (provider === 'anthropic' ? 'Anthropic' : 'OpenAI');
|
|
234
251
|
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
235
|
-
console.error(chalk.red(`\n ✗ No ${
|
|
252
|
+
console.error(chalk.red(`\n ✗ No ${displayLabel} credential found.\n`));
|
|
236
253
|
console.error(chalk.dim(` Run: badgr connect ${provider}\n`));
|
|
237
254
|
return null;
|
|
238
255
|
}
|
|
239
256
|
|
|
240
|
-
console.log(chalk.yellow(`\n ${
|
|
241
|
-
|
|
242
|
-
|
|
257
|
+
console.log(chalk.yellow(`\n ${displayLabel} is not connected.`));
|
|
258
|
+
if (label) {
|
|
259
|
+
console.log(chalk.dim(` Your own ${displayLabel} API key pays for model usage — Badgr credits still pay for the disposable VM.\n`));
|
|
260
|
+
} else {
|
|
261
|
+
console.log(chalk.dim(` ${displayLabel === 'Anthropic' ? 'Claude Code' : 'Codex'} uses your ${displayLabel} account for model usage.`));
|
|
262
|
+
console.log(chalk.dim(' Badgr credits still pay for the disposable VM.\n'));
|
|
263
|
+
}
|
|
243
264
|
|
|
244
265
|
try {
|
|
245
266
|
const { password } = await import('@inquirer/prompts');
|
|
246
267
|
const key = await password({
|
|
247
|
-
message: `Enter your ${
|
|
268
|
+
message: `Enter your ${displayLabel} API key:`,
|
|
248
269
|
validate: v => v.trim() ? true : 'API key is required',
|
|
249
270
|
});
|
|
250
271
|
const trimmed = key.trim();
|
|
251
272
|
if (!trimmed) return null;
|
|
252
273
|
setCredential(provider, trimmed);
|
|
253
|
-
console.log(chalk.green(` ✓ ${
|
|
274
|
+
console.log(chalk.green(` ✓ ${displayLabel} connected\n`));
|
|
254
275
|
return trimmed;
|
|
255
276
|
} catch {
|
|
256
277
|
// Ctrl+C or a non-interactive stdin that lied about isTTY.
|
|
@@ -258,6 +279,53 @@ async function resolveMissingCredential(provider, chalk) {
|
|
|
258
279
|
}
|
|
259
280
|
}
|
|
260
281
|
|
|
282
|
+
/**
|
|
283
|
+
* BYOK / OpenAI-compatible or Anthropic-compatible model selection.
|
|
284
|
+
*
|
|
285
|
+
* `badgr launch <cline|codex> --provider <name> --model <id> [--base-url <url>]`
|
|
286
|
+
* uses apiKind='openai' (injects OPENAI_API_KEY/OPENAI_BASE_URL/MODEL).
|
|
287
|
+
* `badgr launch claude --provider <name> --model <id> [--base-url <url>]`
|
|
288
|
+
* uses apiKind='anthropic' (injects ANTHROPIC_API_KEY/ANTHROPIC_BASE_URL/MODEL).
|
|
289
|
+
*
|
|
290
|
+
* Validates before provisioning — unknown provider, missing --model, or
|
|
291
|
+
* missing --base-url for `custom` all hard-fail here, no VM created.
|
|
292
|
+
* Returns the extra env pairs to inject, or null on validation failure.
|
|
293
|
+
*/
|
|
294
|
+
async function resolveModelProviderEnv(flags, chalk, apiKind = 'openai') {
|
|
295
|
+
const provider = flags.provider;
|
|
296
|
+
if (!(provider in MODEL_PROVIDERS)) {
|
|
297
|
+
console.error(chalk.red(`\n ✗ Unknown model provider: ${provider}`));
|
|
298
|
+
console.error(chalk.dim(` Supported: ${KNOWN_MODEL_PROVIDERS.join(', ')}\n`));
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
if (!flags.model) {
|
|
302
|
+
console.error(chalk.red(`\n ✗ --provider ${provider} requires --model <model-id> — no default model is assumed.\n`));
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
const baseUrl = flags.baseUrl || MODEL_PROVIDERS[provider].defaultBaseUrl;
|
|
306
|
+
if (!baseUrl) {
|
|
307
|
+
console.error(chalk.red(`\n ✗ --provider custom requires --base-url <url>.\n`));
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const envKeys = ENV_KEYS_FOR_API_KIND[apiKind];
|
|
312
|
+
const userEnvKeys = new Set((flags.env || []).map(kv => kv.split('=')[0]));
|
|
313
|
+
const env = [];
|
|
314
|
+
|
|
315
|
+
if (!userEnvKeys.has(envKeys.apiKey)) {
|
|
316
|
+
let credential = getCredential(provider);
|
|
317
|
+
if (!credential) {
|
|
318
|
+
credential = await resolveMissingCredential(provider, chalk, MODEL_PROVIDERS[provider].label);
|
|
319
|
+
if (!credential) return null;
|
|
320
|
+
}
|
|
321
|
+
env.push(`${envKeys.apiKey}=${credential}`);
|
|
322
|
+
}
|
|
323
|
+
if (!userEnvKeys.has(envKeys.model)) env.push(`${envKeys.model}=${flags.model}`);
|
|
324
|
+
if (!userEnvKeys.has(envKeys.baseUrl)) env.push(`${envKeys.baseUrl}=${baseUrl}`);
|
|
325
|
+
|
|
326
|
+
return env;
|
|
327
|
+
}
|
|
328
|
+
|
|
261
329
|
async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
|
|
262
330
|
const { flags, task, gpuRejected } = parseWorkloadShorthandArgs(rawArgs);
|
|
263
331
|
|
|
@@ -275,6 +343,32 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
|
|
|
275
343
|
|
|
276
344
|
warnIfTaskMayHaveSwallowedAFlag(task, chalk);
|
|
277
345
|
|
|
346
|
+
// When --eval-command is given, route to the agent Job type
|
|
347
|
+
// (POST /v1/jobs, type: agent) which tracks check results and stores a
|
|
348
|
+
// receipt — same UX path, different backend record type. Runs after the
|
|
349
|
+
// --gpu / empty-task guards above so those still apply, and calls runJob
|
|
350
|
+
// directly with the already-parsed flags rather than re-serializing them
|
|
351
|
+
// into an argv (which silently dropped everything the re-serializer
|
|
352
|
+
// forgot to list).
|
|
353
|
+
if (flags.evalCommand) {
|
|
354
|
+
const ignored = JOB_API_IGNORED_FLAGS.filter(([, key]) => flags[key] !== undefined);
|
|
355
|
+
if (ignored.length) {
|
|
356
|
+
console.error(chalk.yellow(`\n ⚠ ${ignored.map(([f]) => f).join(', ')} ${ignored.length === 1 ? 'is' : 'are'} not supported with --eval-command and will be ignored.`));
|
|
357
|
+
console.error(chalk.dim(' The Jobs API provisions and configures the VM itself.\n'));
|
|
358
|
+
}
|
|
359
|
+
return runJob(config, {
|
|
360
|
+
agent: agentName,
|
|
361
|
+
instruction: task,
|
|
362
|
+
check: flags.evalCommand,
|
|
363
|
+
provider: flags.provider,
|
|
364
|
+
model: flags.model,
|
|
365
|
+
maxCost: flags.maxCost,
|
|
366
|
+
maxRuntime: flags.maxRuntime,
|
|
367
|
+
dryRun: flags.dryRun,
|
|
368
|
+
detach: flags.detach,
|
|
369
|
+
}, chalk);
|
|
370
|
+
}
|
|
371
|
+
|
|
278
372
|
if (!resolveVmSize(flags, agentName, chalk)) {
|
|
279
373
|
process.exitCode = 1;
|
|
280
374
|
return;
|
|
@@ -282,7 +376,23 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
|
|
|
282
376
|
|
|
283
377
|
const spec = AGENT_WORKLOADS[agentName];
|
|
284
378
|
|
|
285
|
-
if (spec.provider) {
|
|
379
|
+
if (spec.provider && flags.provider) {
|
|
380
|
+
// BYOK override for a fixed-provider workload (claude → anthropic,
|
|
381
|
+
// codex → openai). Skip the managed credential flow and inject the
|
|
382
|
+
// BYOK model provider env vars instead. The apiKind determines which
|
|
383
|
+
// env var names the agent image reads (ANTHROPIC_* vs OPENAI_*).
|
|
384
|
+
const apiKind = spec.provider === 'anthropic' ? 'anthropic' : 'openai';
|
|
385
|
+
const modelEnv = await resolveModelProviderEnv(flags, chalk, apiKind);
|
|
386
|
+
if (!modelEnv) {
|
|
387
|
+
process.exitCode = 1;
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (!flags.env) flags.env = [];
|
|
391
|
+
flags.env.push(...modelEnv);
|
|
392
|
+
flags.authRequired = { provider: flags.provider, status: 'connected' };
|
|
393
|
+
} else if (spec.provider) {
|
|
394
|
+
// Standard managed-credential flow: anthropic → ANTHROPIC_API_KEY,
|
|
395
|
+
// openai → OPENAI_API_KEY. Prompts inline on first use in a TTY.
|
|
286
396
|
const envKey = PROVIDER_ENV_KEYS[spec.provider];
|
|
287
397
|
const userSuppliedKey = flags.env?.some(kv => kv.startsWith(`${envKey}=`));
|
|
288
398
|
let credential = userSuppliedKey ? null : getCredential(spec.provider);
|
|
@@ -309,12 +419,24 @@ async function launchAgentWorkload(config, agentName, rawArgs, chalk) {
|
|
|
309
419
|
if (!flags.env) flags.env = [];
|
|
310
420
|
flags.env.push(`${envKey}=${credential}`);
|
|
311
421
|
}
|
|
422
|
+
} else if (flags.provider) {
|
|
423
|
+
// BYOK for cline (spec.provider is null) — injects OPENAI_API_KEY,
|
|
424
|
+
// OPENAI_BASE_URL, MODEL. The backend skips its managed model token
|
|
425
|
+
// when it sees an existing OPENAI_API_KEY (see jobs_routes.py).
|
|
426
|
+
const modelEnv = await resolveModelProviderEnv(flags, chalk, 'openai');
|
|
427
|
+
if (!modelEnv) {
|
|
428
|
+
process.exitCode = 1;
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (!flags.env) flags.env = [];
|
|
432
|
+
flags.env.push(...modelEnv);
|
|
433
|
+
flags.authRequired = { provider: flags.provider, status: 'connected' };
|
|
312
434
|
} else {
|
|
313
435
|
flags.authRequired = { provider: null, status: 'none' };
|
|
314
436
|
}
|
|
315
|
-
// cline
|
|
316
|
-
//
|
|
317
|
-
//
|
|
437
|
+
// cline with no --provider: Badgr mints a short-lived job-scoped model
|
|
438
|
+
// token server-side (see backend jobs_routes.py); the CLI never handles
|
|
439
|
+
// or displays that token. --provider switches cline to BYOK instead.
|
|
318
440
|
|
|
319
441
|
const cmdArgv = spec.buildCmd(task);
|
|
320
442
|
flags.cmd = displayCmd(cmdArgv);
|
|
@@ -336,6 +458,13 @@ async function launchDirectWorkload(config, workloadName, rawArgs, chalk) {
|
|
|
336
458
|
// there's no natural-language interface to hand it to, unlike the agent
|
|
337
459
|
// workloads above. Read but intentionally not passed to buildCmd().
|
|
338
460
|
|
|
461
|
+
if (flags.provider) {
|
|
462
|
+
console.error(chalk.red(`\n ✗ --provider is not supported for \`badgr launch ${workloadName}\` — it is a test runner with no LLM component.\n`));
|
|
463
|
+
console.error(chalk.dim(` Use \`badgr launch claude/cline/codex\` for AI agent workloads.\n`));
|
|
464
|
+
process.exitCode = 1;
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
|
|
339
468
|
if (gpuRejected) {
|
|
340
469
|
console.error(chalk.red('\n ✗ badgr launch runs on a CPU VM and does not accept --gpu.\n'));
|
|
341
470
|
process.exitCode = 1;
|
package/src/commands/run.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createWriteStream } from 'fs';
|
|
|
5
5
|
import { requireApiKey } from '../config.js';
|
|
6
6
|
import { callApi, terminateDeployment, uploadBlob, quoteRun } from '../api.js';
|
|
7
7
|
import { addReceipt, updateReceipt, generateReceiptId, selectedComputeFromDeployment } from '../store.js';
|
|
8
|
-
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD } from '../fallback.js';
|
|
8
|
+
import { normalizeTier, callWithFallback, HIGH_RATE_THRESHOLD, SMOKE_MAX_COST_USD, SMOKE_MAX_RUNTIME_MINUTES } from '../fallback.js';
|
|
9
9
|
import { formatCliError } from '../errors.js';
|
|
10
10
|
import { TEMPLATE_MAP, buildTemplateFlags, parseTemplateOverrides } from '../catalog.js';
|
|
11
11
|
import { stage as _stage, stageDone as _stageDone, writeBlock as _writeBlock, clearBlock as _clearBlock, renderLiveBlock as _renderLiveBlock, printFailureClass as _printFailureClass, printCapacityPreview, formatTierLabel } from '../progress.js';
|
|
@@ -57,6 +57,7 @@ export function parseRunArgs(args) {
|
|
|
57
57
|
if (flagArgs[i] === '--count') { flags.count = parseInt(flagArgs[++i], 10); i++; continue; }
|
|
58
58
|
if (flagArgs[i] === '--region') { flags.region = flagArgs[++i]; i++; continue; }
|
|
59
59
|
if (flagArgs[i] === '--tier') { flags.tier = flagArgs[++i]; i++; continue; }
|
|
60
|
+
if (flagArgs[i] === '--smoke') { flags.smoke = true; i++; continue; }
|
|
60
61
|
if (flagArgs[i] === '--max-price') { flags.maxPrice = parseFloat(flagArgs[++i]); i++; continue; }
|
|
61
62
|
if (flagArgs[i] === '--name') { flags.name = flagArgs[++i]; i++; continue; }
|
|
62
63
|
if (flagArgs[i] === '--detach') { flags.detach = true; i++; continue; }
|
|
@@ -409,7 +410,7 @@ async function attachToJob(config, depId, { chalk, maxRuntimeMs = null, maxCost
|
|
|
409
410
|
|
|
410
411
|
// Known badgr run flags — used to detect broken shell line continuation.
|
|
411
412
|
const _KNOWN_RUN_FLAGS = new Set([
|
|
412
|
-
'--gpu', '--image', '--count', '--region', '--tier', '--max-price', '--name',
|
|
413
|
+
'--gpu', '--image', '--count', '--region', '--tier', '--smoke', '--max-price', '--name',
|
|
413
414
|
'--detach', '--no-detach', '--fallback', '--no-fallback', '--strict-capacity',
|
|
414
415
|
'--no-expanded-search', '--max-runtime', '--max-cost', '--min-vram', '--gpu-memory',
|
|
415
416
|
'--cpu', '--memory', '--no-gpu', '--env',
|
|
@@ -692,6 +693,28 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
692
693
|
return;
|
|
693
694
|
}
|
|
694
695
|
|
|
696
|
+
// ── Smoke mode: cheapest compatible provider for local/dev test runs ──────
|
|
697
|
+
// --smoke, or BADGR_DEV_CHEAPEST=1 in the environment for a local default so
|
|
698
|
+
// it doesn't have to be typed every time. An explicit --tier always wins —
|
|
699
|
+
// the user asked for a specific tier, so smoke's routing/caps don't apply.
|
|
700
|
+
const devCheapestEnv = process.env.BADGR_DEV_CHEAPEST === '1' || process.env.BADGR_DEV_CHEAPEST === 'true';
|
|
701
|
+
const smokeMode = !flags.tier && (flags.smoke || devCheapestEnv);
|
|
702
|
+
|
|
703
|
+
if (smokeMode) {
|
|
704
|
+
if (flags.detach) {
|
|
705
|
+
console.error(chalk.red(' ✗ --smoke requires teardown to run in the foreground — remove --detach.'));
|
|
706
|
+
process.exitCode = 1;
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
if (flags.workspace) {
|
|
710
|
+
console.error(chalk.red(' ✗ --smoke does not support --workspace (no persistent storage for smoke runs).'));
|
|
711
|
+
process.exitCode = 1;
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
if (flags.maxCost === undefined) flags.maxCost = SMOKE_MAX_COST_USD;
|
|
715
|
+
if (flags.maxRuntime === undefined) flags.maxRuntime = SMOKE_MAX_RUNTIME_MINUTES;
|
|
716
|
+
}
|
|
717
|
+
|
|
695
718
|
if (!flags.maxCost && !flags.dryRun && isLocalPath && process.stdin.isTTY && process.stdout.isTTY) {
|
|
696
719
|
try {
|
|
697
720
|
const { input } = await import('@inquirer/prompts');
|
|
@@ -801,6 +824,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
801
824
|
if (flags.resumeCmd) console.log(` ${chalk.bold('Resume cmd:')} ${flags.resumeCmd}`);
|
|
802
825
|
if (flags.artifacts?.length) console.log(` ${chalk.bold('Artifacts:')} ${flags.artifacts.join(', ')}`);
|
|
803
826
|
if (!isLaunch) console.log(` ${chalk.bold('Tier:')} ${formatTierLabel(effectiveTier)}`);
|
|
827
|
+
if (smokeMode) console.log(` ${chalk.bold('Routing:')} cheapest compatible (smoke mode)`);
|
|
804
828
|
|
|
805
829
|
// Upload-size estimate is GPU-job-specific (spec: "badgr run ... upload
|
|
806
830
|
// size") and does a real local zip pass — skip it for CPU launches
|
|
@@ -848,6 +872,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
848
872
|
console.log(` ${chalk.bold('Max cost:')} ${maxCostLabel}`);
|
|
849
873
|
console.log(` ${chalk.bold('Max runtime:')} ${runtimeLabel}`);
|
|
850
874
|
console.log(` ${chalk.bold('Auto-stop:')} ${maxCost ? 'enabled' : chalk.yellow('disabled — stop manually with badgr down')}`);
|
|
875
|
+
if (smokeMode) console.log(` ${chalk.bold('Routing:')} cheapest compatible (smoke mode)`);
|
|
851
876
|
if (flags.maxPrice) console.log(` ${chalk.bold('Max price:')} $${flags.maxPrice.toFixed(2)}/hr`);
|
|
852
877
|
if (detach) console.log(` ${chalk.dim('(detached — returns immediately)')}`);
|
|
853
878
|
if (flags.env?.length) console.log(` ${chalk.bold('Env:')} ${redactEnvForDisplay(flags.env)}`);
|
|
@@ -912,6 +937,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
912
937
|
max_price_per_hour: flags.maxPrice,
|
|
913
938
|
name: flags.name,
|
|
914
939
|
tier: tierOverride || effectiveTier,
|
|
940
|
+
...(smokeMode ? { routing: 'cheapest' } : {}),
|
|
915
941
|
...(Object.keys(envObj).length > 0 ? { env: envObj } : {}),
|
|
916
942
|
max_runtime_seconds: effectiveMaxRuntime * 60,
|
|
917
943
|
...(maxCost ? { max_cost_usd: maxCost } : {}),
|
|
@@ -936,7 +962,7 @@ export async function runCommand(config, args, chalk, opts = {}) {
|
|
|
936
962
|
effectiveTier,
|
|
937
963
|
chalk,
|
|
938
964
|
{ thing: 'job', cmd: cmdName },
|
|
939
|
-
{ allowTier2Fallback: !flags.noFallback },
|
|
965
|
+
{ allowTier2Fallback: !flags.noFallback, singleAttempt: smokeMode },
|
|
940
966
|
);
|
|
941
967
|
} catch (err) {
|
|
942
968
|
if (err.isPaymentRequired) {
|
package/src/credentials.js
CHANGED
|
@@ -8,10 +8,43 @@ export const CREDENTIALS_FILE = join(CONFIG_DIR, 'credentials.json');
|
|
|
8
8
|
export const PROVIDER_ENV_KEYS = {
|
|
9
9
|
anthropic: 'ANTHROPIC_API_KEY',
|
|
10
10
|
openai: 'OPENAI_API_KEY',
|
|
11
|
+
// OpenAI-compatible BYOK lanes for `badgr launch cline --provider <name>`
|
|
12
|
+
// (see MODEL_PROVIDERS below) — the cline agent image only ever reads
|
|
13
|
+
// OPENAI_API_KEY/OPENAI_BASE_URL/MODEL (images/badgr-agent-cline/
|
|
14
|
+
// badgr-cline-run), regardless of which upstream provider the key
|
|
15
|
+
// actually belongs to.
|
|
16
|
+
openrouter: 'OPENAI_API_KEY',
|
|
17
|
+
deepseek: 'OPENAI_API_KEY',
|
|
18
|
+
glm: 'OPENAI_API_KEY',
|
|
19
|
+
custom: 'OPENAI_API_KEY',
|
|
11
20
|
};
|
|
12
21
|
|
|
13
22
|
export const KNOWN_PROVIDERS = Object.keys(PROVIDER_ENV_KEYS);
|
|
14
23
|
|
|
24
|
+
// `badgr launch cline --provider <name> --model <id> [--base-url <url>]` —
|
|
25
|
+
// BYOK / OpenAI-compatible model selection. `custom` has no default base
|
|
26
|
+
// URL: it must always be supplied explicitly, since there is nothing sane
|
|
27
|
+
// to default it to. Every other entry's defaultBaseUrl is overridable with
|
|
28
|
+
// an explicit --base-url too (e.g. a company-hosted DeepSeek-compatible
|
|
29
|
+
// gateway).
|
|
30
|
+
export const MODEL_PROVIDERS = {
|
|
31
|
+
openrouter: { label: 'OpenRouter', defaultBaseUrl: 'https://openrouter.ai/api/v1' },
|
|
32
|
+
deepseek: { label: 'DeepSeek', defaultBaseUrl: 'https://api.deepseek.com/v1' },
|
|
33
|
+
glm: { label: 'GLM (Zhipu)', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4' },
|
|
34
|
+
custom: { label: 'Custom (OpenAI-compatible)', defaultBaseUrl: null },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const KNOWN_MODEL_PROVIDERS = Object.keys(MODEL_PROVIDERS);
|
|
38
|
+
|
|
39
|
+
// Which env vars each API kind expects inside the agent container.
|
|
40
|
+
// `claude` uses the Anthropic SDK (ANTHROPIC_*); `cline` and `codex` use the
|
|
41
|
+
// OpenAI SDK (OPENAI_*). `model` is always 'MODEL' — every agent wrapper
|
|
42
|
+
// reads that env var and maps it to its own --model flag.
|
|
43
|
+
export const ENV_KEYS_FOR_API_KIND = {
|
|
44
|
+
openai: { apiKey: 'OPENAI_API_KEY', baseUrl: 'OPENAI_BASE_URL', model: 'MODEL' },
|
|
45
|
+
anthropic: { apiKey: 'ANTHROPIC_API_KEY', baseUrl: 'ANTHROPIC_BASE_URL', model: 'MODEL' },
|
|
46
|
+
};
|
|
47
|
+
|
|
15
48
|
/**
|
|
16
49
|
* Credential storage for `badgr connect <provider>`. This is a local file
|
|
17
50
|
* under ~/.badgr with owner-only permissions (chmod 600) — not the
|
package/src/fallback.js
CHANGED
|
@@ -6,6 +6,13 @@ import { CATALOG, formatCliError } from './errors.js';
|
|
|
6
6
|
/** Rates above this threshold trigger a visible warning when no --max-cost is set. */
|
|
7
7
|
export const HIGH_RATE_THRESHOLD = 3.00;
|
|
8
8
|
|
|
9
|
+
// ── badgr run --smoke defaults ────────────────────────────────────────────────
|
|
10
|
+
// Hardcoded default smoke caps, not a config system — --max-cost/--max-runtime
|
|
11
|
+
// still override them when passed explicitly (see run.js). The goal is to stop
|
|
12
|
+
// expensive local smoke tests by default, not build another configuration system.
|
|
13
|
+
export const SMOKE_MAX_COST_USD = 0.25;
|
|
14
|
+
export const SMOKE_MAX_RUNTIME_MINUTES = 10;
|
|
15
|
+
|
|
9
16
|
/** Normalise --tier flag variants to '1' or '2'. */
|
|
10
17
|
export function normalizeTier(tier) {
|
|
11
18
|
return (tier === '2' || tier === 'tier2' || tier === 'tier-2') ? '2' : (tier || '1');
|
|
@@ -37,12 +44,16 @@ export class CapacityError extends Error {
|
|
|
37
44
|
* @param {object} labels - { thing: 'job'|'endpoint', cmd: 'badgr run'|'badgr serve' }
|
|
38
45
|
* @param {object} [opts]
|
|
39
46
|
* @param {boolean} [opts.allowTier2Fallback=true] - set false to disable tier-2 expansion
|
|
47
|
+
* @param {boolean} [opts.singleAttempt=false] - set true to skip both the same-tier
|
|
48
|
+
* provider retry and tier-2 expansion, failing immediately on the first error
|
|
49
|
+
* (badgr run --smoke's "one attempt" guarantee)
|
|
40
50
|
*/
|
|
41
51
|
export async function callWithFallback(endpoint, callOpts, buildBody, effectiveTier, chalk, labels, opts = {}) {
|
|
42
52
|
const { callApi } = await import('./api.js');
|
|
43
53
|
const thing = labels?.thing ?? 'job';
|
|
44
54
|
const cmd = labels?.cmd ?? 'badgr run';
|
|
45
|
-
const
|
|
55
|
+
const singleAttempt = opts.singleAttempt === true;
|
|
56
|
+
const allowTier2Fallback = !singleAttempt && opts.allowTier2Fallback !== false; // default true
|
|
46
57
|
|
|
47
58
|
// 220s: comfortably above backend's BADGR_PROVISION_TIMEOUT_SECONDS (default
|
|
48
59
|
// 200s, itself set above deployment_service.py's 180s routing-search
|
|
@@ -113,7 +124,7 @@ export async function callWithFallback(endpoint, callOpts, buildBody, effectiveT
|
|
|
113
124
|
// Provider retry: PROVISIONING_FAILED means the selected provider couldn't launch the slot.
|
|
114
125
|
// Retry once with prefer_different_provider so the backend routes to a different provider
|
|
115
126
|
// (e.g. RunPod failed → try Vast.ai or Hyperstack) within the same max_cost budget.
|
|
116
|
-
if (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR') {
|
|
127
|
+
if (!singleAttempt && (d?.code === 'PROVISIONING_FAILED' || d?.code === 'PROVIDER_ADAPTER_ERROR')) {
|
|
117
128
|
console.log(chalk.dim('\n Provider unavailable — trying alternative provider...\n'));
|
|
118
129
|
try {
|
|
119
130
|
const retryBody = { ...buildBody(), prefer_different_provider: true };
|
package/src/commands/task.js
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { launchCommand } from './launch.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* badgr task "<description>" -- <command>
|
|
5
|
-
*
|
|
6
|
-
* The MVP version of "assign a task" — a thin label wrapper over
|
|
7
|
-
* `badgr launch . -- <command>`. It does not queue, schedule, or track
|
|
8
|
-
* anything beyond what `badgr launch` already does; the description is
|
|
9
|
-
* printed for the human and otherwise discarded (the receipt/status page
|
|
10
|
-
* still key off the deployment ID, same as any other launch).
|
|
11
|
-
*/
|
|
12
|
-
export async function taskCommand(config, args, chalk) {
|
|
13
|
-
const description = args[0];
|
|
14
|
-
if (!description || description.startsWith('-')) {
|
|
15
|
-
console.error(chalk.red('\nUsage: badgr task "<description>" [badgr launch flags...] -- <command>\n'));
|
|
16
|
-
console.error(chalk.dim(' The description must come first, before any flags.'));
|
|
17
|
-
console.error(chalk.dim(' Example: badgr task "Run the Chromium tests and tell me what failed" --max-cost 1 -- npm run test:chromium'));
|
|
18
|
-
console.error(chalk.dim(' Example: badgr task "Fix the failing checkout test" --max-cost 1 -- claude -p "Fix the failing checkout test"\n'));
|
|
19
|
-
process.exitCode = 1;
|
|
20
|
-
return;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
console.log(chalk.dim(` Task: ${description}`));
|
|
24
|
-
return launchCommand(config, ['.', ...args.slice(1)], chalk);
|
|
25
|
-
}
|