badgr-cli 1.1.2 → 1.1.4
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 +182 -17
- package/package.json +2 -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 +798 -0
- package/src/commands/job.js +246 -0
- package/src/commands/launch.js +144 -15
- package/src/commands/run.js +29 -3
- package/src/commands/serve.js +13 -3
- package/src/credentials.js +33 -0
- package/src/errors.js +5 -0
- package/src/fallback.js +17 -3
- package/src/commands/task.js +0 -25
|
@@ -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/commands/serve.js
CHANGED
|
@@ -357,9 +357,10 @@ export async function serveCommand(config, args, chalk) {
|
|
|
357
357
|
const { model, flags } = parseServeArgs(args);
|
|
358
358
|
const customImage = flags.image || null;
|
|
359
359
|
const isLlamaCpp = flags.runtime === 'llama.cpp';
|
|
360
|
+
const isOllama = flags.runtime === 'ollama';
|
|
360
361
|
|
|
361
362
|
// Expand blessed alias (qwen-7b, llama-8b, qwen-coder-7b) to full model ID + GPU.
|
|
362
|
-
const vllmAlias = model && !customImage && !isLlamaCpp ? BLESSED_VLLM_MODELS[model] : null;
|
|
363
|
+
const vllmAlias = model && !customImage && !isLlamaCpp && !isOllama ? BLESSED_VLLM_MODELS[model] : null;
|
|
363
364
|
const effectiveModel = vllmAlias ? vllmAlias.model_id : model;
|
|
364
365
|
|
|
365
366
|
// Detect flags that ended up as positional args due to broken shell line continuation
|
|
@@ -564,6 +565,7 @@ export async function serveCommand(config, args, chalk) {
|
|
|
564
565
|
return {
|
|
565
566
|
...(effectiveModel ? { model: effectiveModel } : {}),
|
|
566
567
|
...(isLlamaCpp ? { image: LLAMA_CPP_IMAGE } : customImage ? { image: customImage } : {}),
|
|
568
|
+
...(isOllama ? { runtime: 'ollama' } : {}),
|
|
567
569
|
...(flags.task ? { task: flags.task } : {}),
|
|
568
570
|
gpu: gpuOverride || gpu,
|
|
569
571
|
gpu_count: flags.count || 1,
|
|
@@ -649,7 +651,9 @@ export async function serveCommand(config, args, chalk) {
|
|
|
649
651
|
}
|
|
650
652
|
|
|
651
653
|
// ── Determine health check path ───────────────────────────────────────────
|
|
652
|
-
const resolvedHealthPath =
|
|
654
|
+
const resolvedHealthPath = isOllama
|
|
655
|
+
? (flags.healthPath || '/api/tags')
|
|
656
|
+
: _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
|
|
653
657
|
|
|
654
658
|
// Gated-model guidance is only shown when it's actually needed — on failure —
|
|
655
659
|
// not up front, so common launches stay short and uncluttered.
|
|
@@ -770,7 +774,13 @@ export async function serveCommand(config, args, chalk) {
|
|
|
770
774
|
console.log();
|
|
771
775
|
console.log(chalk.dim(' Billing continues until you run: ') + chalk.cyan(`badgr down ${dep.deployment_id}`));
|
|
772
776
|
|
|
773
|
-
if (endpointReady &&
|
|
777
|
+
if (endpointReady && isOllama) {
|
|
778
|
+
console.log(` ${chalk.bold('Test with curl:')}`);
|
|
779
|
+
console.log(chalk.dim(` curl ${endpointUrl}/api/generate \\`));
|
|
780
|
+
console.log(chalk.dim(` -H "Content-Type: application/json" \\`));
|
|
781
|
+
console.log(chalk.dim(` -d '{"model":"${dep.model || effectiveModel}","prompt":"Hello","stream":false}'`));
|
|
782
|
+
console.log();
|
|
783
|
+
} else if (endpointReady && !customImage) {
|
|
774
784
|
// dep.endpoint_api_key is a per-endpoint key generated for this deployment
|
|
775
785
|
// (vLLM model serves only) — shown exactly once, here. Falls back to the
|
|
776
786
|
// account-wide key (truncated) for serves that don't get one yet
|
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
|