@vellumai/cli 0.8.2 → 0.8.3

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
@@ -1,7 +1,7 @@
1
1
  import { randomBytes } from "crypto";
2
2
  import { chmodSync, existsSync, mkdirSync, watch as fsWatch } from "fs";
3
3
  import { arch, platform } from "os";
4
- import { dirname, join } from "path";
4
+ import { dirname, join, resolve } from "path";
5
5
 
6
6
  // Direct import — bun embeds this at compile time so it works in compiled binaries.
7
7
  import cliPkg from "../../package.json";
@@ -12,15 +12,21 @@ import {
12
12
  setActiveAssistant,
13
13
  } from "./assistant-config";
14
14
  import type { AssistantEntry } from "./assistant-config";
15
- import { writeInitialConfig } from "./config-utils";
15
+ import { buildHatchConfigValues, writeInitialConfig } from "./config-utils";
16
16
  import { buildServiceRunArgs } from "./statefulset.js";
17
17
  import type { Species } from "./constants";
18
18
  import { getDefaultPorts } from "./environments/paths.js";
19
19
  import { getCurrentEnvironment } from "./environments/resolve.js";
20
20
  import { leaseGuardianToken } from "./guardian-token";
21
+ import { logHatchNextSteps } from "./hatch-next-steps.js";
21
22
  import { isVellumProcess, stopProcess } from "./process";
22
23
  import { generateInstanceName } from "./random-name";
23
24
  import { resolveImageRefs } from "./platform-releases.js";
25
+ import {
26
+ configureHatchProviderApiKey,
27
+ formatProviderName,
28
+ resolveHatchProvider,
29
+ } from "./provider-secrets.js";
24
30
  import { exec, execOutput } from "./step-runner";
25
31
  import {
26
32
  closeLogFile,
@@ -40,10 +46,13 @@ export const DOCKERHUB_IMAGES: Record<ServiceName, string> = {
40
46
  };
41
47
 
42
48
  /** Internal ports exposed by each service's Dockerfile. Re-exported from environments/paths.ts. */
43
- export { ASSISTANT_INTERNAL_PORT, GATEWAY_INTERNAL_PORT } from "./environments/paths.js";
49
+ export {
50
+ ASSISTANT_INTERNAL_PORT,
51
+ GATEWAY_INTERNAL_PORT,
52
+ } from "./environments/paths.js";
44
53
 
45
54
  /** Max time to wait for the assistant container to emit the readiness sentinel. */
46
- export const DOCKER_READY_TIMEOUT_MS = 3 * 60 * 1000;
55
+ export const DOCKER_READY_TIMEOUT_MS = 5 * 60 * 1000;
47
56
 
48
57
  /** Default virtual-camera device path. Overridable via `VELLUM_AVATAR_DEVICE`. */
49
58
  const DEFAULT_AVATAR_DEVICE_PATH = "/dev/video10";
@@ -247,6 +256,28 @@ function ensureLocalBinOnPath(): void {
247
256
  }
248
257
  }
249
258
 
259
+ export interface HatchDockerOptions {
260
+ setupProviderCredentials?: boolean;
261
+ }
262
+
263
+ export type DockerProviderCredentialSetupAction =
264
+ | "configure"
265
+ | "defer"
266
+ | "missing-token"
267
+ | "skip";
268
+
269
+ export function resolveDockerProviderCredentialSetupAction(options: {
270
+ provider: string | null | undefined;
271
+ guardianAccessToken?: string;
272
+ detached: boolean;
273
+ }): DockerProviderCredentialSetupAction {
274
+ if (options.provider === undefined) return "skip";
275
+ if (options.provider === null) return options.detached ? "skip" : "configure";
276
+ if (options.detached) return "defer";
277
+ if (!options.guardianAccessToken) return "missing-token";
278
+ return "configure";
279
+ }
280
+
250
281
  /**
251
282
  * Checks whether the `docker` CLI and daemon are available on the system.
252
283
  * Installs Colima and Docker via direct binary download if missing (no sudo
@@ -461,6 +492,37 @@ function hasFullSourceTree(root: string): boolean {
461
492
  return existsSync(join(root, "assistant", "package.json"));
462
493
  }
463
494
 
495
+ /**
496
+ * Decide which image-source path `hatchDocker` should take given the user
497
+ * flags and a probe result for the source tree.
498
+ *
499
+ * - `watch` always wants source-build *and* file-watcher (when the source
500
+ * tree is available).
501
+ * - `buildFromSource` wants source-build but no watcher — used by evals so
502
+ * each run picks up fresh CLI changes from the repo.
503
+ * - Without either flag we pull the published images.
504
+ * - If either flag was set but the source tree is missing (e.g. the CLI is
505
+ * running from a packaged .app bundle), fall back to pulling and surface
506
+ * the reason so the caller can log a warning.
507
+ *
508
+ * Returning a plain record keeps this trivially unit-testable — see
509
+ * `__tests__/docker.test.ts`.
510
+ */
511
+ export function resolveDockerHatchMode(opts: {
512
+ watch: boolean;
513
+ buildFromSource: boolean;
514
+ fullSourceTreeAvailable: boolean;
515
+ }): { build: boolean; watcher: boolean; fellBackToPull: boolean } {
516
+ const requested = opts.watch || opts.buildFromSource;
517
+ if (!requested) {
518
+ return { build: false, watcher: false, fellBackToPull: false };
519
+ }
520
+ if (!opts.fullSourceTreeAvailable) {
521
+ return { build: false, watcher: false, fellBackToPull: true };
522
+ }
523
+ return { build: true, watcher: opts.watch, fellBackToPull: false };
524
+ }
525
+
464
526
  /**
465
527
  * Locate the repository root by walking up from `cli/src/lib/` until we
466
528
  * find a directory containing the expected Dockerfiles.
@@ -581,7 +643,10 @@ export async function startContainers(
581
643
  },
582
644
  log: (msg: string) => void,
583
645
  ): Promise<void> {
584
- const runArgs = buildServiceRunArgs({ ...opts, avatarDevicePath: resolveAvatarDevicePath() });
646
+ const runArgs = buildServiceRunArgs({
647
+ ...opts,
648
+ avatarDevicePath: resolveAvatarDevicePath(),
649
+ });
585
650
  for (const service of SERVICE_START_ORDER) {
586
651
  log(`🚀 Starting ${service} container...`);
587
652
  await exec("docker", runArgs[service]());
@@ -870,14 +935,32 @@ function startFileWatcher(opts: {
870
935
  };
871
936
  }
872
937
 
938
+ export interface HatchDockerOptions {
939
+ /**
940
+ * Path to a local source tree to build images from before hatching. When
941
+ * provided, this path is used directly as the repo root and no file
942
+ * watcher is started — useful for callers (e.g. evals) that want each
943
+ * run to pick up local CLI changes without keeping a long-lived watcher
944
+ * process around. `--watch` independently auto-detects the repo root and
945
+ * also enables hot-reload.
946
+ */
947
+ sourcePath?: string | null;
948
+ analyze?: boolean;
949
+ }
950
+
873
951
  export async function hatchDocker(
874
952
  species: Species,
875
953
  detached: boolean,
876
954
  name: string | null,
877
955
  watch: boolean = false,
878
956
  configValues: Record<string, string> = {},
957
+ options: HatchDockerOptions = {},
879
958
  ): Promise<void> {
880
959
  resetLogFile("hatch.log");
960
+ const provider =
961
+ options.setupProviderCredentials === false
962
+ ? undefined
963
+ : resolveHatchProvider(configValues);
881
964
 
882
965
  let logFd = openLogFile("hatch.log");
883
966
  const log = (msg: string): void => {
@@ -898,25 +981,42 @@ export async function hatchDocker(
898
981
  gateway: "",
899
982
  };
900
983
 
984
+ const sourcePath =
985
+ typeof options.sourcePath === "string" && options.sourcePath.length > 0
986
+ ? options.sourcePath
987
+ : null;
988
+ const buildFromSource = sourcePath !== null;
901
989
  let repoRoot: string | undefined;
990
+ let fullSourceTreeAvailable = false;
902
991
 
903
- if (watch) {
904
- repoRoot = findRepoRoot();
992
+ if (watch || buildFromSource) {
993
+ // When --source <path> is supplied, trust the caller and use it
994
+ // directly. Otherwise (the --watch case) walk up from known locations
995
+ // to find the repo root.
996
+ repoRoot = sourcePath ? resolve(sourcePath) : findRepoRoot();
905
997
 
906
998
  // When running from a packaged .app bundle, the Dockerfiles are
907
999
  // present (so findRepoRoot succeeds) but the full source tree is
908
1000
  // not — we can't build images locally. Fall back to pulling
909
1001
  // pre-built images instead.
910
- if (!hasFullSourceTree(repoRoot)) {
1002
+ fullSourceTreeAvailable = hasFullSourceTree(repoRoot);
1003
+ if (!fullSourceTreeAvailable) {
911
1004
  log(
912
1005
  "⚠️ Dockerfiles found but no source tree — falling back to image pull",
913
1006
  );
914
- watch = false;
915
1007
  repoRoot = undefined;
916
1008
  }
917
1009
  }
918
1010
 
919
- if (watch && repoRoot) {
1011
+ const mode = resolveDockerHatchMode({
1012
+ watch,
1013
+ buildFromSource,
1014
+ fullSourceTreeAvailable,
1015
+ });
1016
+ // Honour the resolved mode for the rest of the flow.
1017
+ watch = mode.watcher;
1018
+
1019
+ if (mode.build && repoRoot) {
920
1020
  emitProgress(2, 6, "Building images...");
921
1021
  const localTag = `local-${instanceName}`;
922
1022
  imageTags.assistant = `vellum-assistant:${localTag}`;
@@ -926,7 +1026,9 @@ export async function hatchDocker(
926
1026
 
927
1027
  log(`🥚 Hatching Docker assistant: ${instanceName}`);
928
1028
  log(` Species: ${species}`);
929
- log(` Mode: development (watch)`);
1029
+ log(
1030
+ ` Mode: ${mode.watcher ? "development (watch)" : "build-from-source"}`,
1031
+ );
930
1032
  log(` Repo: ${repoRoot}`);
931
1033
  log(` Images (local build):`);
932
1034
  log(` assistant: ${imageTags.assistant}`);
@@ -938,7 +1040,7 @@ export async function hatchDocker(
938
1040
  log("✅ Docker images built");
939
1041
  }
940
1042
 
941
- if (!watch || !repoRoot) {
1043
+ if (!mode.build || !repoRoot) {
942
1044
  emitProgress(2, 6, "Pulling images...");
943
1045
 
944
1046
  // Allow explicit image overrides via environment variables.
@@ -1013,7 +1115,8 @@ export async function hatchDocker(
1013
1115
 
1014
1116
  // Write --config key=value pairs to a temp file that gets bind-mounted
1015
1117
  // into the assistant container and read via VELLUM_DEFAULT_WORKSPACE_CONFIG_PATH.
1016
- const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
1118
+ const hatchConfigValues = buildHatchConfigValues(configValues, provider);
1119
+ const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
1017
1120
 
1018
1121
  const cesServiceToken = randomBytes(32).toString("hex");
1019
1122
  const signingKey = randomBytes(32).toString("hex");
@@ -1043,6 +1146,7 @@ export async function hatchDocker(
1043
1146
  },
1044
1147
  log,
1045
1148
  );
1149
+ const containersUpAt = Date.now();
1046
1150
 
1047
1151
  const imageDigests = await captureImageRefs(res);
1048
1152
 
@@ -1053,6 +1157,7 @@ export async function hatchDocker(
1053
1157
  cloud: "docker",
1054
1158
  species,
1055
1159
  hatchedAt: new Date().toISOString(),
1160
+ guardianBootstrapSecret: ownSecret,
1056
1161
  containerInfo: {
1057
1162
  assistantImage: imageTags.assistant,
1058
1163
  gatewayImage: imageTags.gateway,
@@ -1068,19 +1173,59 @@ export async function hatchDocker(
1068
1173
  setActiveAssistant(instanceName);
1069
1174
 
1070
1175
  emitProgress(6, 6, "Waiting for services...");
1071
- const { ready } = await waitForGatewayAndLease({
1176
+ const waitDetached = watch ? false : detached;
1177
+ const { ready, guardianAccessToken } = await waitForGatewayAndLease({
1072
1178
  bootstrapSecret: ownSecret,
1073
1179
  containerName: res.assistantContainer,
1074
- detached: watch ? false : detached,
1180
+ detached: waitDetached,
1075
1181
  instanceName,
1076
1182
  logFd,
1077
1183
  runtimeUrl,
1184
+ containersUpAt,
1185
+ analyze: options.analyze ?? false,
1078
1186
  });
1079
1187
 
1080
1188
  if (!ready && !(watch && repoRoot)) {
1081
1189
  throw new Error("Timed out waiting for assistant to become ready");
1082
1190
  }
1083
1191
 
1192
+ if (ready) {
1193
+ const providerSetupAction = resolveDockerProviderCredentialSetupAction({
1194
+ provider,
1195
+ guardianAccessToken,
1196
+ detached: waitDetached,
1197
+ });
1198
+
1199
+ if (providerSetupAction === "defer" && provider !== null) {
1200
+ log(
1201
+ `Provider credential setup deferred in detached mode.\n` +
1202
+ `Run \`vellum setup --provider ${provider}\` after the assistant is ready.`,
1203
+ );
1204
+ } else if (providerSetupAction === "missing-token" && provider !== null) {
1205
+ log(
1206
+ `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
1207
+ ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`,
1208
+ );
1209
+ } else if (
1210
+ providerSetupAction === "configure" &&
1211
+ provider !== undefined
1212
+ ) {
1213
+ log(
1214
+ provider === null
1215
+ ? "Checking provider credentials..."
1216
+ : `Checking ${formatProviderName(provider)} credentials...`,
1217
+ );
1218
+ await configureHatchProviderApiKey({
1219
+ gatewayUrl: runtimeUrl,
1220
+ provider,
1221
+ bearerToken: guardianAccessToken,
1222
+ env: process.env,
1223
+ log,
1224
+ });
1225
+ }
1226
+ logHatchNextSteps(log, instanceName);
1227
+ }
1228
+
1084
1229
  if (watch && repoRoot) {
1085
1230
  saveAssistantEntry({ ...dockerEntry, watcherPid: process.pid });
1086
1231
 
@@ -1136,7 +1281,9 @@ async function waitForGatewayAndLease(opts: {
1136
1281
  instanceName: string;
1137
1282
  logFd: number | "ignore";
1138
1283
  runtimeUrl: string;
1139
- }): Promise<{ ready: boolean }> {
1284
+ containersUpAt: number;
1285
+ analyze: boolean;
1286
+ }): Promise<{ ready: boolean; guardianAccessToken?: string }> {
1140
1287
  const {
1141
1288
  bootstrapSecret,
1142
1289
  containerName,
@@ -1144,6 +1291,8 @@ async function waitForGatewayAndLease(opts: {
1144
1291
  instanceName,
1145
1292
  logFd,
1146
1293
  runtimeUrl,
1294
+ containersUpAt,
1295
+ analyze,
1147
1296
  } = opts;
1148
1297
 
1149
1298
  const log = (msg: string): void => {
@@ -1168,7 +1317,7 @@ async function waitForGatewayAndLease(opts: {
1168
1317
  log("Waiting for assistant to become ready...");
1169
1318
 
1170
1319
  const readyUrl = `${runtimeUrl}/readyz`;
1171
- const start = Date.now();
1320
+ const start = containersUpAt;
1172
1321
  let ready = false;
1173
1322
 
1174
1323
  while (Date.now() - start < DOCKER_READY_TIMEOUT_MS) {
@@ -1204,8 +1353,18 @@ async function waitForGatewayAndLease(opts: {
1204
1353
  return { ready: false };
1205
1354
  }
1206
1355
 
1207
- const elapsedSec = ((Date.now() - start) / 1000).toFixed(1);
1356
+ const readyAt = Date.now();
1357
+ const containersUpToReadyMs = readyAt - start;
1358
+ const elapsedSec = (containersUpToReadyMs / 1000).toFixed(1);
1208
1359
  log(`Assistant ready after ${elapsedSec}s`);
1360
+ if (analyze) {
1361
+ console.info(
1362
+ `[vellum-hatch-timing] ${JSON.stringify({
1363
+ containers_up_to_ready_ms: containersUpToReadyMs,
1364
+ instance: instanceName,
1365
+ })}`,
1366
+ );
1367
+ }
1209
1368
 
1210
1369
  // Lease guardian token. The /readyz check confirms both gateway and
1211
1370
  // assistant are reachable. Retry with backoff in case there is a brief
@@ -1215,6 +1374,7 @@ async function waitForGatewayAndLease(opts: {
1215
1374
  const leaseDeadline = start + DOCKER_READY_TIMEOUT_MS;
1216
1375
  let leaseSuccess = false;
1217
1376
  let lastLeaseError: string | undefined;
1377
+ let guardianAccessToken: string | undefined;
1218
1378
 
1219
1379
  while (Date.now() < leaseDeadline) {
1220
1380
  try {
@@ -1227,6 +1387,7 @@ async function waitForGatewayAndLease(opts: {
1227
1387
  log(
1228
1388
  `Guardian token lease: success after ${leaseElapsed}s (principalId=${tokenData.guardianPrincipalId}, expiresAt=${tokenData.accessTokenExpiresAt})`,
1229
1389
  );
1390
+ guardianAccessToken = tokenData.accessToken;
1230
1391
  leaseSuccess = true;
1231
1392
  break;
1232
1393
  } catch (err) {
@@ -1257,5 +1418,5 @@ async function waitForGatewayAndLease(opts: {
1257
1418
  log(` Name: ${instanceName}`);
1258
1419
  log(` Runtime: ${runtimeUrl}`);
1259
1420
  log("");
1260
- return { ready: true };
1421
+ return { ready: true, guardianAccessToken };
1261
1422
  }
@@ -22,7 +22,7 @@ import {
22
22
  } from "./assistant-config.js";
23
23
  import type { AssistantEntry } from "./assistant-config.js";
24
24
  import type { Species } from "./constants.js";
25
- import { writeInitialConfig } from "./config-utils.js";
25
+ import { buildHatchConfigValues, writeInitialConfig } from "./config-utils.js";
26
26
  import {
27
27
  generateLocalSigningKey,
28
28
  startLocalDaemon,
@@ -34,6 +34,12 @@ import { generateInstanceName } from "./random-name.js";
34
34
  import { leaseGuardianToken } from "./guardian-token.js";
35
35
  import { archiveLogFile, resetLogFile } from "./xdg-log.js";
36
36
  import { emitProgress } from "./desktop-progress.js";
37
+ import {
38
+ configureHatchProviderApiKey,
39
+ formatProviderName,
40
+ resolveHatchProvider,
41
+ } from "./provider-secrets.js";
42
+ import { logHatchNextSteps } from "./hatch-next-steps.js";
37
43
 
38
44
  /**
39
45
  * Attempts to place a symlink at the given path pointing to cliBinary.
@@ -125,13 +131,22 @@ function installCLISymlink(): void {
125
131
  );
126
132
  }
127
133
 
134
+ export interface HatchLocalOptions {
135
+ setupProviderCredentials?: boolean;
136
+ }
137
+
128
138
  export async function hatchLocal(
129
139
  species: Species,
130
140
  name: string | null,
131
141
  watch: boolean = false,
132
142
  keepAlive: boolean = false,
133
143
  configValues: Record<string, string> = {},
144
+ options: HatchLocalOptions = {},
134
145
  ): Promise<void> {
146
+ const provider =
147
+ options.setupProviderCredentials === false
148
+ ? undefined
149
+ : resolveHatchProvider(configValues);
135
150
  const instanceName = generateInstanceName(
136
151
  species,
137
152
  name ?? process.env.VELLUM_ASSISTANT_NAME,
@@ -168,7 +183,8 @@ export async function hatchLocal(
168
183
  }
169
184
 
170
185
  emitProgress(2, 6, "Writing configuration...");
171
- const defaultWorkspaceConfigPath = writeInitialConfig(configValues);
186
+ const hatchConfigValues = buildHatchConfigValues(configValues, provider);
187
+ const defaultWorkspaceConfigPath = writeInitialConfig(hatchConfigValues);
172
188
 
173
189
  emitProgress(3, 6, "Starting assistant...");
174
190
  const signingKey = generateLocalSigningKey();
@@ -198,9 +214,11 @@ export async function hatchLocal(
198
214
  emitProgress(5, 6, "Securing connection...");
199
215
  const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
200
216
  const maxLeaseAttempts = 3;
217
+ let guardianAccessToken: string | undefined;
201
218
  for (let attempt = 1; attempt <= maxLeaseAttempts; attempt++) {
202
219
  try {
203
- await leaseGuardianToken(loopbackUrl, instanceName);
220
+ const tokenData = await leaseGuardianToken(loopbackUrl, instanceName);
221
+ guardianAccessToken = tokenData.accessToken;
204
222
  break;
205
223
  } catch (err) {
206
224
  if (attempt < maxLeaseAttempts) {
@@ -238,6 +256,26 @@ export async function hatchLocal(
238
256
  installCLISymlink();
239
257
  }
240
258
 
259
+ if (provider !== undefined && provider !== null && !guardianAccessToken) {
260
+ console.error(
261
+ `⚠️ Provider credential setup skipped because the guardian token was not leased.\n` +
262
+ ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the connection.`,
263
+ );
264
+ } else if (provider !== undefined) {
265
+ console.log("");
266
+ console.log(
267
+ provider === null
268
+ ? "Checking provider credentials..."
269
+ : `Checking ${formatProviderName(provider)} credentials...`,
270
+ );
271
+ await configureHatchProviderApiKey({
272
+ gatewayUrl: loopbackUrl,
273
+ provider,
274
+ bearerToken: guardianAccessToken,
275
+ env: process.env,
276
+ });
277
+ }
278
+
241
279
  console.log("");
242
280
  console.log(`✅ Local assistant hatched!`);
243
281
  console.log("");
@@ -245,6 +283,7 @@ export async function hatchLocal(
245
283
  console.log(` Name: ${instanceName}`);
246
284
  console.log(` Runtime: ${runtimeUrl}`);
247
285
  console.log("");
286
+ logHatchNextSteps(console.log, instanceName);
248
287
 
249
288
  if (keepAlive) {
250
289
  const healthUrl = `http://127.0.0.1:${resources.gatewayPort}/healthz`;
@@ -0,0 +1,12 @@
1
+ export function logHatchNextSteps(
2
+ log: (message: string) => void,
3
+ instanceName: string,
4
+ ): void {
5
+ log("Next steps:");
6
+ log(" vellum client");
7
+ log(' vellum message "hello"');
8
+ log(" vellum events");
9
+ log(" vellum ps");
10
+ log(` vellum use ${instanceName}`);
11
+ log("");
12
+ }
@@ -52,6 +52,19 @@ export interface GatewayApiKeyReadResult {
52
52
  unreachable: boolean;
53
53
  }
54
54
 
55
+ export interface HatchProviderApiKeyOptions {
56
+ gatewayUrl: string;
57
+ provider: LlmProviderId | null;
58
+ bearerToken?: string;
59
+ env?: NodeJS.ProcessEnv;
60
+ fetchImpl?: ProviderSecretFetch;
61
+ log?: (message: string) => void;
62
+ prompt?: (prompt: string) => Promise<string>;
63
+ stdinIsTTY?: boolean;
64
+ input?: NodeJS.ReadStream;
65
+ output?: NodeJS.WriteStream;
66
+ }
67
+
55
68
  const PROVIDER_LABELS: Record<LlmProviderId, string> = {
56
69
  anthropic: "Anthropic",
57
70
  openai: "OpenAI",
@@ -70,6 +83,84 @@ export function isSupportedLlmProvider(
70
83
  return Object.hasOwn(LLM_PROVIDER_ENV_VAR_NAMES, provider);
71
84
  }
72
85
 
86
+ export function resolveHatchProvider(
87
+ configValues: Record<string, string | undefined>,
88
+ ): LlmProviderId | null {
89
+ const provider = (
90
+ resolveConfiguredMainAgentProvider(configValues) || "anthropic"
91
+ ).toLowerCase();
92
+
93
+ if (provider === "ollama") {
94
+ return null;
95
+ }
96
+
97
+ if (!isSupportedLlmProvider(provider)) {
98
+ throw new Error(
99
+ `Provider '${provider}' does not have a supported API-key setup flow.`,
100
+ );
101
+ }
102
+
103
+ return provider;
104
+ }
105
+
106
+ function resolveConfiguredMainAgentProvider(
107
+ configValues: Record<string, string | undefined>,
108
+ ): string | undefined {
109
+ // Fresh hatches seed the active custom profile from llm.default and then
110
+ // that active profile wins over static mainAgent call-site defaults. Match
111
+ // that startup behavior so hatch prompts for the provider the assistant will
112
+ // actually use on first chat.
113
+ return (
114
+ resolveProfileProvider(
115
+ configValues,
116
+ readConfigValue(configValues, "llm.activeProfile"),
117
+ ) ??
118
+ resolveFragmentProvider(configValues, "llm.default") ??
119
+ resolveFragmentProvider(configValues, "llm.callSites.mainAgent") ??
120
+ resolveProfileProvider(
121
+ configValues,
122
+ readConfigValue(configValues, "llm.callSites.mainAgent.profile"),
123
+ )
124
+ );
125
+ }
126
+
127
+ function resolveProfileProvider(
128
+ configValues: Record<string, string | undefined>,
129
+ profileName: string | undefined,
130
+ ): string | undefined {
131
+ if (!profileName) return undefined;
132
+ return resolveFragmentProvider(configValues, `llm.profiles.${profileName}`);
133
+ }
134
+
135
+ function resolveFragmentProvider(
136
+ configValues: Record<string, string | undefined>,
137
+ prefix: string,
138
+ ): string | undefined {
139
+ const provider = readConfigValue(configValues, `${prefix}.provider`);
140
+ if (provider) return provider;
141
+
142
+ const model = readConfigValue(configValues, `${prefix}.model`);
143
+ return model ? inferProviderFromModel(model) : undefined;
144
+ }
145
+
146
+ function readConfigValue(
147
+ configValues: Record<string, string | undefined>,
148
+ key: string,
149
+ ): string | undefined {
150
+ const value = configValues[key]?.trim();
151
+ return value && value.length > 0 ? value : undefined;
152
+ }
153
+
154
+ function inferProviderFromModel(model: string): string | undefined {
155
+ if (model.startsWith("claude-")) return "anthropic";
156
+ if (model.startsWith("gpt-")) return "openai";
157
+ if (model.startsWith("gemini-")) return "gemini";
158
+ if (model.startsWith("accounts/fireworks/models/")) return "fireworks";
159
+ if (model.includes("/")) return "openrouter";
160
+ if (model === "llama3.2" || model === "mistral") return "ollama";
161
+ return undefined;
162
+ }
163
+
73
164
  function gatewayUrlWithPath(gatewayUrl: string, path: string): string {
74
165
  return `${gatewayUrl.replace(/\/+$/, "")}${path}`;
75
166
  }
@@ -411,3 +502,63 @@ export async function ensureProviderApiKey(
411
502
  source,
412
503
  };
413
504
  }
505
+
506
+ export async function configureHatchProviderApiKey(
507
+ options: HatchProviderApiKeyOptions,
508
+ ): Promise<void> {
509
+ const log = options.log ?? console.log;
510
+ const { provider } = options;
511
+
512
+ if (provider === null) {
513
+ log("Provider credentials not required for the selected provider.");
514
+ return;
515
+ }
516
+
517
+ try {
518
+ const result = await ensureProviderApiKey({
519
+ gatewayUrl: options.gatewayUrl,
520
+ provider,
521
+ bearerToken: options.bearerToken,
522
+ env: options.env,
523
+ fetchImpl: options.fetchImpl,
524
+ prompt: options.prompt,
525
+ stdinIsTTY: options.stdinIsTTY,
526
+ input: options.input,
527
+ output: options.output,
528
+ });
529
+
530
+ if (result.status === "already_configured") {
531
+ log(
532
+ `Provider credentials already configured for ${formatProviderName(result.provider)}.`,
533
+ );
534
+ return;
535
+ }
536
+
537
+ if (result.status === "configured") {
538
+ if (result.source === "env") {
539
+ log(
540
+ `Configured ${formatProviderName(result.provider)} credentials from ${LLM_PROVIDER_ENV_VAR_NAMES[result.provider]}.`,
541
+ );
542
+ } else {
543
+ log(`Configured ${formatProviderName(result.provider)} credentials.`);
544
+ }
545
+ return;
546
+ }
547
+
548
+ if (result.status === "skipped") {
549
+ log(result.message);
550
+ return;
551
+ }
552
+
553
+ log(
554
+ `⚠️ Provider credential setup skipped: ${result.message}\n` +
555
+ ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` to finish setup.`,
556
+ );
557
+ } catch (error) {
558
+ const message = error instanceof Error ? error.message : String(error);
559
+ log(
560
+ `⚠️ Provider credential setup failed: ${message}\n` +
561
+ ` The assistant is still hatched. Run \`vellum setup --provider ${provider}\` after fixing the issue.`,
562
+ );
563
+ }
564
+ }
@@ -26,9 +26,6 @@ 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
- zai: "ZAI_API_KEY",
30
- deepseek: "DEEPSEEK_API_KEY",
31
- minimax: "MINIMAX_API_KEY",
32
29
  };
33
30
 
34
31
  /** Search-provider env var names. Mirrors `SEARCH_PROVIDER_CATALOG` BYOK entries. */