@vellumai/cli 0.8.4 → 0.8.5

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,40 @@
1
+ import { LLM_PROVIDER_ENV_VAR_NAMES } from "../shared/provider-env-vars.js";
2
+
3
+ export interface ApiKeyCheckResult {
4
+ hasKey: boolean;
5
+ }
6
+
7
+ /**
8
+ * Returns true when a key value is a real credential rather than a placeholder.
9
+ *
10
+ * .env.example ships values like `sk-ant-...`, `sk-...`, and `...` to show
11
+ * where credentials go. Any value containing `...` or that is empty is treated
12
+ * as a placeholder that the user has not replaced yet.
13
+ */
14
+ function isPlaceholder(value: string | undefined): boolean {
15
+ if (!value || value.trim() === "") return true;
16
+ if (value.includes("...")) return true;
17
+ return false;
18
+ }
19
+
20
+ /**
21
+ * Check whether at least one LLM provider API key is configured in the
22
+ * current process environment.
23
+ *
24
+ * The CLI's job is to spawn the daemon and pass configuration via environment
25
+ * variables — it does not read from the .vellum/ directory (see AGENTS.md).
26
+ * Checking process.env is sufficient: the daemon forwards whatever is set
27
+ * in the environment, so exporting a key before running `vellum hatch` is
28
+ * the correct way to supply it.
29
+ *
30
+ * Uses the canonical LLM provider env-var catalog so the list stays in sync
31
+ * automatically as new providers are added.
32
+ */
33
+ export function checkProviderApiKey(): ApiKeyCheckResult {
34
+ for (const envVar of Object.values(LLM_PROVIDER_ENV_VAR_NAMES)) {
35
+ if (!isPlaceholder(process.env[envVar])) {
36
+ return { hasKey: true };
37
+ }
38
+ }
39
+ return { hasKey: false };
40
+ }
package/src/lib/docker.ts CHANGED
@@ -12,7 +12,6 @@ import {
12
12
  setActiveAssistant,
13
13
  } from "./assistant-config";
14
14
  import type { AssistantEntry } from "./assistant-config";
15
- import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
16
15
  import { buildServiceRunArgs } from "./statefulset.js";
17
16
  import type { Species } from "./constants";
18
17
  import { getDefaultPorts } from "./environments/paths.js";
@@ -35,6 +34,7 @@ import {
35
34
  formatProviderName,
36
35
  resolveHatchProvider,
37
36
  } from "./provider-secrets.js";
37
+ import { findOpenPort } from "./port-allocator.js";
38
38
  import { exec, execOutput } from "./step-runner";
39
39
  import {
40
40
  closeLogFile,
@@ -645,7 +645,6 @@ export async function startContainers(
645
645
  extraAssistantEnv?: Record<string, string>;
646
646
  gatewayPort: number;
647
647
  imageTags: Record<ServiceName, string>;
648
- defaultWorkspaceConfigPath?: string;
649
648
  instanceName: string;
650
649
  res: ReturnType<typeof dockerResourceNames>;
651
650
  },
@@ -981,7 +980,22 @@ export async function hatchDocker(
981
980
  await ensureDockerInstalled();
982
981
 
983
982
  const instanceName = generateInstanceName(species, name);
984
- const gatewayPort = getDefaultPorts(getCurrentEnvironment()).gateway;
983
+ // Resolve the gateway's host port dynamically. The env-default
984
+ // (production 7830 / non-prod overrides) is just the *preferred*
985
+ // starting point — if it's taken by another local assistant, eval
986
+ // run, or unrelated process, we walk upward until we find a free
987
+ // port. This replaces the previous "first one in wins, everyone
988
+ // else gets a docker bind error" behavior and removes the need for
989
+ // an orphan-cleanup pre-flight in the evals harness.
990
+ const preferredGatewayPort = getDefaultPorts(
991
+ getCurrentEnvironment(),
992
+ ).gateway;
993
+ const gatewayPort = await findOpenPort(preferredGatewayPort);
994
+ if (gatewayPort !== preferredGatewayPort) {
995
+ log(
996
+ `Preferred gateway port ${preferredGatewayPort} is in use; allocated ${gatewayPort} for this instance.`,
997
+ );
998
+ }
985
999
 
986
1000
  const imageTags: Record<ServiceName, string> = {
987
1001
  assistant: "",
@@ -1150,10 +1164,11 @@ export async function hatchDocker(
1150
1164
  "chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
1151
1165
  ]);
1152
1166
 
1153
- // Write --config key=value pairs to a temp file that gets bind-mounted
1154
- // into the assistant container and read via VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH.
1155
- const hatchConfigValues = buildHatchConfigValues(configValues, provider);
1156
- const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
1167
+ // BYOK setup (API key, custom profiles, active-profile selection) is
1168
+ // driven post-boot by the CLI calling the Assistant's public APIs
1169
+ // (`POST /v1/secrets`, etc.) via `configureHatchProviderApiKey` below.
1170
+ // The Assistant container comes up clean — no overlay file, no
1171
+ // client-side workspace-config injection.
1157
1172
 
1158
1173
  const cesServiceToken = randomBytes(32).toString("hex");
1159
1174
  const signingKey = randomBytes(32).toString("hex");
@@ -1177,7 +1192,6 @@ export async function hatchDocker(
1177
1192
  cesServiceToken,
1178
1193
  gatewayPort,
1179
1194
  imageTags,
1180
- defaultWorkspaceConfigPath,
1181
1195
  instanceName,
1182
1196
  res,
1183
1197
  },
@@ -40,6 +40,7 @@ import {
40
40
  resolveHatchProvider,
41
41
  } from "./provider-secrets.js";
42
42
  import { logHatchNextSteps } from "./hatch-next-steps.js";
43
+ import { checkProviderApiKey } from "./api-key-check.js";
43
44
 
44
45
  /**
45
46
  * Attempts to place a symlink at the given path pointing to cliBinary.
@@ -178,6 +179,16 @@ export async function hatchLocal(
178
179
  console.log(` Species: ${species}`);
179
180
  console.log("");
180
181
 
182
+ const apiKeyCheck = checkProviderApiKey();
183
+ if (!apiKeyCheck.hasKey) {
184
+ console.warn(
185
+ "Warning: No LLM provider API key is configured. The assistant will fail when you try to send a message.",
186
+ );
187
+ console.warn(" To fix, export your key before running vellum hatch:");
188
+ console.warn(" export ANTHROPIC_API_KEY=<your-key>");
189
+ console.warn("");
190
+ }
191
+
181
192
  if (!process.env.APP_VERSION) {
182
193
  process.env.APP_VERSION = cliPkg.version;
183
194
  }
@@ -0,0 +1,93 @@
1
+ import { createServer } from "net";
2
+
3
+ /**
4
+ * Walks upward from `preferred` and returns the first host port that the
5
+ * kernel will let us bind to. Used by `hatchDocker` to pick the gateway's
6
+ * host-side port instead of always grabbing the env-default (e.g. 7830 /
7
+ * 20100), which collides with any other local assistant — eval-spawned or
8
+ * otherwise — already bound there.
9
+ *
10
+ * The previous design (`evals/src/lib/orphan-cleanup.ts`) tried to fix this
11
+ * by sweeping dead eval-run resources before the next hatch. That only
12
+ * helped when the conflict came from a prior eval run; an unrelated local
13
+ * `vellum hatch` holding the port wedged the whole flow. Discovering an
14
+ * open port at hatch time is the proper fix and lets us delete the cleanup
15
+ * pre-flight entirely.
16
+ *
17
+ * Walks linearly from `preferred` upward rather than asking the kernel for
18
+ * an arbitrary ephemeral port (`listen(0)`) so the resulting port stays
19
+ * legible to operators — three local assistants land on N, N+1, N+2
20
+ * instead of three random numbers in the 32768-60999 range.
21
+ */
22
+ export async function findOpenPort(
23
+ preferred: number,
24
+ options: { maxAttempts?: number; host?: string } = {},
25
+ ): Promise<number> {
26
+ const maxAttempts = options.maxAttempts ?? 50;
27
+ const host = options.host ?? "0.0.0.0";
28
+
29
+ if (!Number.isInteger(preferred) || preferred < 1 || preferred > 65535) {
30
+ throw new Error(
31
+ `findOpenPort: preferred port ${preferred} is not a valid TCP port`,
32
+ );
33
+ }
34
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
35
+ throw new Error(
36
+ `findOpenPort: maxAttempts ${maxAttempts} must be a positive integer`,
37
+ );
38
+ }
39
+
40
+ let lastError: Error | null = null;
41
+ for (let offset = 0; offset < maxAttempts; offset++) {
42
+ const port = preferred + offset;
43
+ if (port > 65535) break;
44
+ try {
45
+ await probePort(port, host);
46
+ return port;
47
+ } catch (err) {
48
+ lastError = err as Error;
49
+ if (!isPortInUseError(err)) {
50
+ // EACCES / EPERM / etc. are not "try the next port" signals — those
51
+ // are configuration problems an operator needs to see immediately.
52
+ throw err;
53
+ }
54
+ }
55
+ }
56
+ throw new Error(
57
+ `findOpenPort: no open port in range [${preferred}, ${preferred + maxAttempts - 1}]` +
58
+ (lastError ? ` (last error: ${lastError.message})` : ""),
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Resolves if `port` on `host` can be bound right now. Rejects with the
64
+ * server's `error` event (typically `EADDRINUSE`) otherwise. Always closes
65
+ * the probe server before resolving so we don't leak the port we just
66
+ * proved was free.
67
+ */
68
+ function probePort(port: number, host: string): Promise<void> {
69
+ return new Promise((resolve, reject) => {
70
+ const server = createServer();
71
+ const cleanup = (cb: () => void): void => {
72
+ server.removeAllListeners();
73
+ server.close(() => cb());
74
+ };
75
+ server.once("error", (err) => {
76
+ // close() on a server that never listened is a no-op; calling it
77
+ // anyway keeps cleanup uniform.
78
+ cleanup(() => reject(err));
79
+ });
80
+ server.once("listening", () => {
81
+ cleanup(() => resolve());
82
+ });
83
+ server.listen(port, host);
84
+ });
85
+ }
86
+
87
+ function isPortInUseError(err: unknown): boolean {
88
+ if (err instanceof Error && "code" in err) {
89
+ const code = (err as NodeJS.ErrnoException).code;
90
+ return code === "EADDRINUSE" || code === "EADDRNOTAVAIL";
91
+ }
92
+ return false;
93
+ }
@@ -257,7 +257,6 @@ export interface BuildServiceRunArgsOpts extends DockerRunSecrets {
257
257
  instanceName: string;
258
258
  res: DockerResourceNames;
259
259
  extraAssistantEnv?: Record<string, string>;
260
- defaultWorkspaceConfigPath?: string;
261
260
  /** Avatar device path, if available. Injected by `docker.ts` after resolving. */
262
261
  avatarDevicePath?: string;
263
262
  }
@@ -286,7 +285,6 @@ export function buildServiceRunArgs(
286
285
  instanceName,
287
286
  res,
288
287
  extraAssistantEnv,
289
- defaultWorkspaceConfigPath,
290
288
  avatarDevicePath,
291
289
  } = opts;
292
290
 
@@ -355,14 +353,6 @@ export function buildServiceRunArgs(
355
353
  "-e", `GATEWAY_INTERNAL_URL=http://localhost:${GATEWAY_INTERNAL_PORT}`,
356
354
  );
357
355
 
358
- if (defaultWorkspaceConfigPath) {
359
- const cPath = `/tmp/vellum-default-workspace-config-${Date.now()}.json`;
360
- args.push(
361
- "-v", `${defaultWorkspaceConfigPath}:${cPath}:ro`,
362
- "-e", `VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH=${cPath}`,
363
- );
364
- }
365
-
366
356
  if (extraAssistantEnv) {
367
357
  for (const [k, v] of Object.entries(extraAssistantEnv)) {
368
358
  args.push("-e", `${k}=${v}`);
@@ -1,5 +1,38 @@
1
1
  import { spawn } from "child_process";
2
2
 
3
+ /**
4
+ * Build the error message for a failed child process. **Never include the
5
+ * argv** — `docker run ...` invocations carry `-e ANTHROPIC_API_KEY=…` /
6
+ * `-e OPENAI_API_KEY=…` style flags, and the resulting `Error.message`
7
+ * propagates all the way to:
8
+ *
9
+ * - the CLI's top-level catch (`console.error("Error:", err.message)`)
10
+ * which leaks them onto stderr,
11
+ * - `subprocess-*.log` files captured by the evals harness when it
12
+ * spawns `vellum hatch` (which then becomes the inlined log on the
13
+ * run-detail report page),
14
+ * - `run.json#error` and the last-N-lines tail in `progress.ndjson`
15
+ * that the evals harness emits for `SubprocessFailedError`.
16
+ *
17
+ * The diagnostic substring callers actually grep for ("no such container",
18
+ * "is not running", "port is already allocated", …) lives in the child's
19
+ * stderr/stdout, which we DO preserve below. Keep the command name only —
20
+ * it's enough to disambiguate which step failed without quoting secrets.
21
+ *
22
+ * Exported so the unit test can assert no `-e KEY=...` slips back in.
23
+ */
24
+ export function buildExecErrorMessage(
25
+ command: string,
26
+ code: number | null,
27
+ stderr: string,
28
+ stdout: string,
29
+ ): string {
30
+ const codeLabel = code === null ? "an unknown code" : `code ${code}`;
31
+ const header = `${command} exited with ${codeLabel}`;
32
+ const output = [stderr.trim(), stdout.trim()].filter(Boolean).join("\n");
33
+ return output ? `${header}\n${output}` : header;
34
+ }
35
+
3
36
  export function exec(
4
37
  command: string,
5
38
  args: string[],
@@ -25,11 +58,7 @@ export function exec(
25
58
  if (code === 0) {
26
59
  resolve();
27
60
  } else {
28
- const msg = `"${command} ${args.join(" ")}" exited with code ${code}`;
29
- const output = [stderr.trim(), stdout.trim()]
30
- .filter(Boolean)
31
- .join("\n");
32
- reject(new Error(output ? `${msg}\n${output}` : msg));
61
+ reject(new Error(buildExecErrorMessage(command, code, stderr, stdout)));
33
62
  }
34
63
  });
35
64
  child.on("error", reject);
@@ -61,8 +90,12 @@ export function execOutput(
61
90
  if (code === 0) {
62
91
  resolve(stdout.trim());
63
92
  } else {
64
- const msg = `"${command} ${args.join(" ")}" exited with code ${code}`;
65
- reject(new Error(stderr.trim() ? `${msg}\n${stderr.trim()}` : msg));
93
+ // execOutput intentionally drops stdout from the error message
94
+ // (callers that read stdout via the success path don't expect
95
+ // partial stdout to land in error.message). Stderr is enough
96
+ // for diagnostics, and the no-args-in-message guarantee from
97
+ // exec() still holds.
98
+ reject(new Error(buildExecErrorMessage(command, code, stderr, "")));
66
99
  }
67
100
  });
68
101
  child.on("error", reject);
@@ -26,6 +26,7 @@ export const LLM_PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
26
26
  gemini: "GEMINI_API_KEY",
27
27
  fireworks: "FIREWORKS_API_KEY",
28
28
  openrouter: "OPENROUTER_API_KEY",
29
+ minimax: "MINIMAX_API_KEY",
29
30
  };
30
31
 
31
32
  /** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */