@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.
- package/AGENTS.md +11 -1
- package/package.json +1 -1
- package/src/__tests__/api-key-check.test.ts +78 -0
- package/src/__tests__/retire.test.ts +241 -0
- package/src/commands/retire.ts +144 -34
- package/src/commands/roadmap.ts +449 -0
- package/src/index.ts +3 -0
- package/src/lib/__tests__/port-allocator.test.ts +117 -0
- package/src/lib/__tests__/step-runner.test.ts +85 -0
- package/src/lib/api-key-check.ts +40 -0
- package/src/lib/docker.ts +22 -8
- package/src/lib/hatch-local.ts +11 -0
- package/src/lib/port-allocator.ts +93 -0
- package/src/lib/statefulset.ts +0 -10
- package/src/lib/step-runner.ts +40 -7
- package/src/shared/provider-env-vars.ts +1 -0
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1154
|
-
//
|
|
1155
|
-
|
|
1156
|
-
|
|
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
|
},
|
package/src/lib/hatch-local.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/lib/statefulset.ts
CHANGED
|
@@ -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}`);
|
package/src/lib/step-runner.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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. */
|