@vellumai/cli 0.8.3 → 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/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";
@@ -21,12 +20,21 @@ import { leaseGuardianToken } from "./guardian-token";
21
20
  import { logHatchNextSteps } from "./hatch-next-steps.js";
22
21
  import { isVellumProcess, stopProcess } from "./process";
23
22
  import { generateInstanceName } from "./random-name";
24
- import { resolveImageRefs } from "./platform-releases.js";
23
+ import {
24
+ HOST_IMAGE_LOADER_URL,
25
+ isLocalBuildRef,
26
+ loadImageViaHost,
27
+ } from "./host-image-loader.js";
28
+ import {
29
+ fetchLatestStableVersion,
30
+ resolveImageRefs,
31
+ } from "./platform-releases.js";
25
32
  import {
26
33
  configureHatchProviderApiKey,
27
34
  formatProviderName,
28
35
  resolveHatchProvider,
29
36
  } from "./provider-secrets.js";
37
+ import { findOpenPort } from "./port-allocator.js";
30
38
  import { exec, execOutput } from "./step-runner";
31
39
  import {
32
40
  closeLogFile,
@@ -637,7 +645,6 @@ export async function startContainers(
637
645
  extraAssistantEnv?: Record<string, string>;
638
646
  gatewayPort: number;
639
647
  imageTags: Record<ServiceName, string>;
640
- defaultWorkspaceConfigPath?: string;
641
648
  instanceName: string;
642
649
  res: ReturnType<typeof dockerResourceNames>;
643
650
  },
@@ -973,7 +980,22 @@ export async function hatchDocker(
973
980
  await ensureDockerInstalled();
974
981
 
975
982
  const instanceName = generateInstanceName(species, name);
976
- 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
+ }
977
999
 
978
1000
  const imageTags: Record<ServiceName, string> = {
979
1001
  assistant: "",
@@ -1059,8 +1081,23 @@ export async function hatchDocker(
1059
1081
  imageSource = "env override";
1060
1082
  log("Using image overrides from environment variables");
1061
1083
  } else {
1062
- const version = cliPkg.version;
1063
- const versionTag = version ? `v${version}` : "latest";
1084
+ // Resolve image refs from a remote source that may have dev/local
1085
+ // builds. If resolution is unavailable, fall back to the CLI's own
1086
+ // version so a default tag can still be resolved.
1087
+ log("🔍 Fetching latest stable release...");
1088
+ const latestVersion = await fetchLatestStableVersion();
1089
+ let versionTag: string;
1090
+ if (latestVersion) {
1091
+ versionTag = latestVersion.startsWith("v")
1092
+ ? latestVersion
1093
+ : `v${latestVersion}`;
1094
+ } else {
1095
+ const fallback = cliPkg.version;
1096
+ versionTag = fallback ? `v${fallback}` : "latest";
1097
+ log(
1098
+ `⚠️ Platform releases unavailable; falling back to CLI version ${versionTag}`,
1099
+ );
1100
+ }
1064
1101
  log("🔍 Resolving image references...");
1065
1102
  const resolved = await resolveImageRefs(versionTag, log);
1066
1103
  imageTags.assistant = resolved.imageTags.assistant;
@@ -1078,11 +1115,25 @@ export async function hatchDocker(
1078
1115
  log(` credential-executor: ${imageTags["credential-executor"]}`);
1079
1116
  log("");
1080
1117
 
1081
- log("📦 Pulling Docker images...");
1082
- await exec("docker", ["pull", imageTags.assistant]);
1083
- await exec("docker", ["pull", imageTags.gateway]);
1084
- await exec("docker", ["pull", imageTags["credential-executor"]]);
1085
- log("✅ Docker images pulled");
1118
+ // Per-ref branching: local-build refs need the image-loader; external
1119
+ // registry refs get a normal `docker pull`. The two transports compose
1120
+ // cleanly a release can mix different sources for different images.
1121
+ log("📦 Acquiring Docker images...");
1122
+ for (const service of [
1123
+ "assistant",
1124
+ "gateway",
1125
+ "credential-executor",
1126
+ ] as const) {
1127
+ const ref = imageTags[service];
1128
+ if (isLocalBuildRef(ref)) {
1129
+ log(` ↪ loading ${ref} via host image-loader`);
1130
+ await loadImageViaHost(HOST_IMAGE_LOADER_URL, ref, log);
1131
+ } else {
1132
+ log(` ↪ pulling ${ref}`);
1133
+ await exec("docker", ["pull", ref]);
1134
+ }
1135
+ }
1136
+ log("✅ Docker images acquired");
1086
1137
  }
1087
1138
 
1088
1139
  const res = dockerResourceNames(instanceName);
@@ -1113,10 +1164,11 @@ export async function hatchDocker(
1113
1164
  "chown 1001:1001 /workspace /run/assistant-ipc /run/gateway-ipc",
1114
1165
  ]);
1115
1166
 
1116
- // Write --config key=value pairs to a temp file that gets bind-mounted
1117
- // into the assistant container and read via VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH.
1118
- const hatchConfigValues = buildHatchConfigValues(configValues, provider);
1119
- 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.
1120
1172
 
1121
1173
  const cesServiceToken = randomBytes(32).toString("hex");
1122
1174
  const signingKey = randomBytes(32).toString("hex");
@@ -1140,7 +1192,6 @@ export async function hatchDocker(
1140
1192
  cesServiceToken,
1141
1193
  gatewayPort,
1142
1194
  imageTags,
1143
- defaultWorkspaceConfigPath,
1144
1195
  instanceName,
1145
1196
  res,
1146
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,138 @@
1
+ /**
2
+ * Client for the host-side image-loader endpoint. Used to acquire image refs
3
+ * that aren't pullable from any external registry.
4
+ *
5
+ * The endpoint URL is a well-known convention — port 5500 on 127.0.0.1.
6
+ * The CLI calls in whenever it sees a ref that starts with `vellum-local/`,
7
+ * which are image refs that only exist in a local docker daemon and can't be
8
+ * `docker pull`'d from any external registry.
9
+ *
10
+ * The endpoint contract is intentionally minimal — POST a ref as JSON, get
11
+ * back a 200 once the image is in the host docker daemon, or a non-2xx
12
+ * with a descriptive error message. The client doesn't know (or care) what
13
+ * transport the server uses to put the image there.
14
+ */
15
+
16
+ /**
17
+ * Well-known URL of the host-side image-loader server.
18
+ */
19
+ export const HOST_IMAGE_LOADER_URL = "http://127.0.0.1:5500/v1/images/load";
20
+
21
+ /**
22
+ * Prefix for image refs that only exist in a local docker daemon.
23
+ * These cannot be `docker pull`'d from any external registry; the CLI must
24
+ * route them through the host image-loader instead.
25
+ */
26
+ const LOCAL_BUILD_REF_PREFIX = "vellum-local/";
27
+
28
+ /** Whether `ref` points at a local-build image that requires the host loader. */
29
+ export function isLocalBuildRef(ref: string): boolean {
30
+ return ref.startsWith(LOCAL_BUILD_REF_PREFIX);
31
+ }
32
+
33
+ /** Default timeout for image-load requests. Large `docker save | docker load`
34
+ * pipelines for full assistant images can run for a minute or two on cold
35
+ * caches, so we give plenty of headroom. */
36
+ const LOAD_TIMEOUT_MS = 120_000;
37
+
38
+ export interface HostImageLoaderResponse {
39
+ loaded?: boolean;
40
+ ref?: string;
41
+ error?: string;
42
+ }
43
+
44
+ export class HostImageLoaderError extends Error {
45
+ readonly url: string;
46
+ readonly ref: string;
47
+ readonly status?: number;
48
+
49
+ constructor(message: string, url: string, ref: string, status?: number) {
50
+ super(message);
51
+ this.name = "HostImageLoaderError";
52
+ this.url = url;
53
+ this.ref = ref;
54
+ this.status = status;
55
+ }
56
+ }
57
+
58
+ function isConnectionRefused(err: unknown): boolean {
59
+ if (!err || typeof err !== "object") return false;
60
+ const e = err as { cause?: { code?: string }; code?: string };
61
+ return e.cause?.code === "ECONNREFUSED" || e.code === "ECONNREFUSED";
62
+ }
63
+
64
+ /**
65
+ * Ask the host-side loader to acquire `ref` into the host docker daemon.
66
+ *
67
+ * Resolves when the server returns 200; throws a {@link HostImageLoaderError}
68
+ * with a user-actionable message on any failure (network, timeout, non-2xx).
69
+ *
70
+ * The `log` callback receives one-line status updates; pass the same logger
71
+ * the surrounding command uses.
72
+ */
73
+ /** Minimal fetch signature accepted for test injection. */
74
+ export type FetchLike = (
75
+ input: string | URL,
76
+ init?: {
77
+ method?: string;
78
+ headers?: Record<string, string>;
79
+ body?: string;
80
+ signal?: AbortSignal;
81
+ },
82
+ ) => Promise<Response>;
83
+
84
+ export async function loadImageViaHost(
85
+ url: string,
86
+ ref: string,
87
+ log: (msg: string) => void,
88
+ options: { timeoutMs?: number; fetchImpl?: FetchLike } = {},
89
+ ): Promise<void> {
90
+ const timeoutMs = options.timeoutMs ?? LOAD_TIMEOUT_MS;
91
+ const fetchImpl: FetchLike =
92
+ options.fetchImpl ?? (fetch as unknown as FetchLike);
93
+
94
+ log(` ↪ ${ref}`);
95
+
96
+ let response: Response;
97
+ try {
98
+ response = await fetchImpl(url, {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ ref }),
102
+ signal: AbortSignal.timeout(timeoutMs),
103
+ });
104
+ } catch (err) {
105
+ if (isConnectionRefused(err)) {
106
+ throw new HostImageLoaderError(
107
+ `Could not reach image-loader at ${url}. The ref \`${ref}\` is a ` +
108
+ `local-build image that requires the loader. Is the loader running? ` +
109
+ `Start it, or set VELLUM_ASSISTANT_IMAGE / VELLUM_GATEWAY_IMAGE / ` +
110
+ `VELLUM_CREDENTIAL_EXECUTOR_IMAGE to bypass image resolution.`,
111
+ url,
112
+ ref,
113
+ );
114
+ }
115
+ const message = err instanceof Error ? err.message : String(err);
116
+ throw new HostImageLoaderError(
117
+ `Image-loader request for ${ref} failed: ${message}`,
118
+ url,
119
+ ref,
120
+ );
121
+ }
122
+
123
+ if (!response.ok) {
124
+ let body: HostImageLoaderResponse | null = null;
125
+ try {
126
+ body = (await response.json()) as HostImageLoaderResponse;
127
+ } catch {
128
+ // Server returned non-JSON; fall through with status-only error.
129
+ }
130
+ const detail = body?.error ? `: ${body.error}` : "";
131
+ throw new HostImageLoaderError(
132
+ `Image-loader returned HTTP ${response.status} for ${ref}${detail}`,
133
+ url,
134
+ ref,
135
+ response.status,
136
+ );
137
+ }
138
+ }
@@ -46,7 +46,10 @@ export async function resolveImageRefs(
46
46
  const platformRefs = await fetchPlatformImageRefs(version, log);
47
47
  if (platformRefs) {
48
48
  log?.("Resolved image refs from platform API");
49
- return { imageTags: platformRefs, source: "platform" };
49
+ return {
50
+ imageTags: platformRefs.imageTags,
51
+ source: "platform",
52
+ };
50
53
  }
51
54
 
52
55
  log?.("Falling back to DockerHub tags");
@@ -68,7 +71,9 @@ export async function resolveImageRefs(
68
71
  async function fetchPlatformImageRefs(
69
72
  version: string,
70
73
  log?: (msg: string) => void,
71
- ): Promise<Record<ServiceName, string> | null> {
74
+ ): Promise<{
75
+ imageTags: Record<ServiceName, string>;
76
+ } | null> {
72
77
  try {
73
78
  const platformUrl = getPlatformUrl();
74
79
  const url = `${platformUrl}/v1/releases/?stable=true`;
@@ -123,9 +128,11 @@ async function fetchPlatformImageRefs(
123
128
  }
124
129
 
125
130
  return {
126
- assistant: assistantImage,
127
- "credential-executor": credentialExecutorImage,
128
- gateway: gatewayImage,
131
+ imageTags: {
132
+ assistant: assistantImage,
133
+ "credential-executor": credentialExecutorImage,
134
+ gateway: gatewayImage,
135
+ },
129
136
  };
130
137
  } catch (err) {
131
138
  const message = err instanceof Error ? err.message : String(err);
@@ -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. */