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.
@@ -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
+ }
@@ -1,5 +1,6 @@
1
1
  import { runCommand, parseRunArgs } from './run.js';
2
- import { getCredential, setCredential, PROVIDER_ENV_KEYS } from '../credentials.js';
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], provider: null },
47
- claude: { image: process.env.BADGR_AGENT_IMAGE_CLAUDE || 'ghcr.io/michaelmanly/badgr-agent-claude:latest', buildCmd: task => ['claude', '-p', task], provider: 'anthropic' },
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 providerLabel = provider === 'anthropic' ? 'Anthropic' : 'OpenAI';
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 ${provider} credential found.\n`));
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 ${providerLabel} is not connected.`));
241
- console.log(chalk.dim(` ${providerLabel === 'Anthropic' ? 'Claude Code' : 'Codex'} uses your ${providerLabel} account for model usage.`));
242
- console.log(chalk.dim(' Badgr credits still pay for the disposable VM.\n'));
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 ${providerLabel} API key:`,
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(` ✓ ${providerLabel} connected\n`));
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 has no `provider` Badgr mints and injects its own short-lived
316
- // job-scoped model token server-side (see backend jobs_routes.py); the
317
- // CLI never handles or displays that token.
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;
@@ -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) {
@@ -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 = _resolveHealthPath({ healthPath: flags.healthPath, isLlamaCpp, customImage, task: flags.task });
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 && !customImage) {
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
@@ -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