@vellumai/cli 0.5.16 → 0.6.1

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.
@@ -308,10 +308,13 @@ export async function hatchLocal(
308
308
  }
309
309
 
310
310
  // Lease a guardian token so the desktop app can import it on first launch
311
- // instead of hitting /v1/guardian/init itself.
311
+ // instead of hitting /v1/guardian/init itself. Use loopback to satisfy
312
+ // the daemon's local-only check — the mDNS runtimeUrl resolves to a LAN
313
+ // IP which the daemon rejects as non-loopback.
312
314
  emitProgress(6, 7, "Securing connection...");
315
+ const loopbackUrl = `http://127.0.0.1:${resources.gatewayPort}`;
313
316
  try {
314
- await leaseGuardianToken(runtimeUrl, instanceName);
317
+ await leaseGuardianToken(loopbackUrl, instanceName);
315
318
  } catch (err) {
316
319
  console.error(`⚠️ Guardian token lease failed: ${err}`);
317
320
  }
@@ -25,10 +25,10 @@ export async function checkManagedHealth(
25
25
  };
26
26
  }
27
27
 
28
- let orgId: string;
28
+ let headers: Record<string, string>;
29
29
  try {
30
- const { fetchOrganizationId } = await import("./platform-client.js");
31
- orgId = await fetchOrganizationId(token, runtimeUrl);
30
+ const { authHeaders } = await import("./platform-client.js");
31
+ headers = await authHeaders(token, runtimeUrl);
32
32
  } catch (err) {
33
33
  return {
34
34
  status: "error (auth)",
@@ -44,11 +44,6 @@ export async function checkManagedHealth(
44
44
  HEALTH_CHECK_TIMEOUT_MS,
45
45
  );
46
46
 
47
- const headers: Record<string, string> = {
48
- "X-Session-Token": token,
49
- "Vellum-Organization-Id": orgId,
50
- };
51
-
52
47
  const response = await fetch(url, {
53
48
  signal: controller.signal,
54
49
  headers,
package/src/lib/ngrok.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { execFileSync, spawn, type ChildProcess } from "node:child_process";
2
2
  import {
3
+ closeSync,
3
4
  existsSync,
4
5
  mkdirSync,
5
6
  openSync,
@@ -130,10 +131,11 @@ export function startNgrokProcess(
130
131
  logFilePath?: string,
131
132
  ): ChildProcess {
132
133
  let stdio: ("ignore" | "pipe" | number)[] = ["ignore", "pipe", "pipe"];
134
+ let fd: number | undefined;
133
135
  if (logFilePath) {
134
136
  const dir = dirname(logFilePath);
135
137
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
136
- const fd = openSync(logFilePath, "a");
138
+ fd = openSync(logFilePath, "a");
137
139
  stdio = ["ignore", fd, fd];
138
140
  }
139
141
 
@@ -141,6 +143,14 @@ export function startNgrokProcess(
141
143
  detached: true,
142
144
  stdio,
143
145
  });
146
+
147
+ // The child process inherits a duplicate of the fd via dup2, so the
148
+ // parent's copy is no longer needed. Close it to avoid leaking the
149
+ // file descriptor for the lifetime of the parent process.
150
+ if (fd !== undefined) {
151
+ closeSync(fd);
152
+ }
153
+
144
154
  return child;
145
155
  }
146
156
 
@@ -68,34 +68,94 @@ export function clearPlatformToken(): void {
68
68
  const VAK_PREFIX = "vak_";
69
69
 
70
70
  /**
71
- * Returns the appropriate auth header for the given platform token.
71
+ * Sync helper returns only the token-based auth header.
72
72
  *
73
- * - `vak_`-prefixed tokens are long-lived platform API keys and use
74
- * `Authorization: Bearer`.
75
- * - All other tokens are allauth session tokens and use `X-Session-Token`.
73
+ * Used internally by {@link fetchOrganizationId} (which cannot call the
74
+ * async {@link authHeaders} without creating a cycle) and by functions
75
+ * that already have an org ID in hand.
76
76
  */
77
- export function authHeaders(token: string): Record<string, string> {
77
+ function tokenAuthHeader(token: string): Record<string, string> {
78
78
  if (token.startsWith(VAK_PREFIX)) {
79
79
  return { Authorization: `Bearer ${token}` };
80
80
  }
81
81
  return { "X-Session-Token": token };
82
82
  }
83
83
 
84
+ /** Module-level cache for org IDs to avoid redundant fetches in polling loops. */
85
+ const orgIdCache = new Map<string, { orgId: string; expiresAt: number }>();
86
+ const ORG_ID_CACHE_TTL_MS = 60_000; // 60 seconds
87
+
88
+ /**
89
+ * Returns the full set of headers needed for an authenticated platform
90
+ * API request:
91
+ *
92
+ * - `Content-Type: application/json`
93
+ * - The appropriate auth header (`Authorization: Bearer` for `vak_`
94
+ * API keys, `X-Session-Token` for session tokens).
95
+ * - `Vellum-Organization-Id` – fetched from the platform. Only
96
+ * included for session-token callers; API keys are already org-scoped.
97
+ *
98
+ * The org ID is cached per (token, platformUrl) for 60 seconds to avoid
99
+ * redundant HTTP requests in tight polling loops.
100
+ *
101
+ * Auth errors (401 / 403) from the org-ID fetch are logged with a
102
+ * user-friendly message before re-throwing, so callers don't need to
103
+ * repeat that logic.
104
+ */
105
+ export async function authHeaders(
106
+ token: string,
107
+ platformUrl?: string,
108
+ ): Promise<Record<string, string>> {
109
+ const base: Record<string, string> = {
110
+ "Content-Type": "application/json",
111
+ ...tokenAuthHeader(token),
112
+ };
113
+
114
+ if (token.startsWith(VAK_PREFIX)) {
115
+ // API keys are org-scoped – no need to fetch the org ID.
116
+ return base;
117
+ }
118
+
119
+ const cacheKey = `${token}::${platformUrl ?? ""}`;
120
+ const cached = orgIdCache.get(cacheKey);
121
+ if (cached && Date.now() < cached.expiresAt) {
122
+ return { ...base, "Vellum-Organization-Id": cached.orgId };
123
+ }
124
+
125
+ try {
126
+ const orgId = await fetchOrganizationId(token, platformUrl);
127
+ orgIdCache.set(cacheKey, {
128
+ orgId,
129
+ expiresAt: Date.now() + ORG_ID_CACHE_TTL_MS,
130
+ });
131
+ return { ...base, "Vellum-Organization-Id": orgId };
132
+ } catch (err) {
133
+ const msg = err instanceof Error ? err.message : String(err);
134
+ if (msg.includes("401") || msg.includes("403")) {
135
+ console.error("Authentication failed. Run 'vellum login' to refresh.");
136
+ } else {
137
+ console.error(`Failed to fetch organization: ${msg}`);
138
+ }
139
+ throw err;
140
+ }
141
+ }
142
+
84
143
  export interface HatchedAssistant {
85
144
  id: string;
86
145
  name: string;
87
146
  status: string;
88
147
  }
89
148
 
90
- export async function hatchAssistant(token: string): Promise<HatchedAssistant> {
91
- const url = `${getPlatformUrl()}/v1/assistants/hatch/`;
149
+ export async function hatchAssistant(
150
+ token: string,
151
+ platformUrl?: string,
152
+ ): Promise<HatchedAssistant> {
153
+ const resolvedUrl = platformUrl || getPlatformUrl();
154
+ const url = `${resolvedUrl}/v1/assistants/hatch/`;
92
155
 
93
156
  const response = await fetch(url, {
94
157
  method: "POST",
95
- headers: {
96
- "Content-Type": "application/json",
97
- ...authHeaders(token),
98
- },
158
+ headers: await authHeaders(token, platformUrl),
99
159
  body: JSON.stringify({}),
100
160
  });
101
161
 
@@ -143,7 +203,7 @@ export async function fetchOrganizationId(
143
203
  const resolvedUrl = platformUrl || getPlatformUrl();
144
204
  const url = `${resolvedUrl}/v1/organizations/`;
145
205
  const response = await fetch(url, {
146
- headers: { "X-Session-Token": token },
206
+ headers: { ...tokenAuthHeader(token) },
147
207
  });
148
208
 
149
209
  if (!response.ok) {
@@ -204,18 +264,13 @@ export async function fetchCurrentUser(
204
264
 
205
265
  export async function rollbackPlatformAssistant(
206
266
  token: string,
207
- orgId: string,
208
267
  version?: string,
209
268
  platformUrl?: string,
210
269
  ): Promise<{ detail: string; version: string | null }> {
211
270
  const resolvedUrl = platformUrl || getPlatformUrl();
212
271
  const response = await fetch(`${resolvedUrl}/v1/assistants/rollback/`, {
213
272
  method: "POST",
214
- headers: {
215
- "Content-Type": "application/json",
216
- "X-Session-Token": token,
217
- "Vellum-Organization-Id": orgId,
218
- },
273
+ headers: await authHeaders(token, platformUrl),
219
274
  body: JSON.stringify(version ? { version } : {}),
220
275
  });
221
276
 
@@ -249,18 +304,13 @@ export async function rollbackPlatformAssistant(
249
304
 
250
305
  export async function platformInitiateExport(
251
306
  token: string,
252
- orgId: string,
253
307
  description?: string,
254
308
  platformUrl?: string,
255
309
  ): Promise<{ jobId: string; status: string }> {
256
310
  const resolvedUrl = platformUrl || getPlatformUrl();
257
311
  const response = await fetch(`${resolvedUrl}/v1/migrations/export/`, {
258
312
  method: "POST",
259
- headers: {
260
- "Content-Type": "application/json",
261
- "X-Session-Token": token,
262
- "Vellum-Organization-Id": orgId,
263
- },
313
+ headers: await authHeaders(token, platformUrl),
264
314
  body: JSON.stringify({ description: description ?? "CLI backup" }),
265
315
  });
266
316
 
@@ -284,17 +334,13 @@ export async function platformInitiateExport(
284
334
  export async function platformPollExportStatus(
285
335
  jobId: string,
286
336
  token: string,
287
- orgId: string,
288
337
  platformUrl?: string,
289
338
  ): Promise<{ status: string; downloadUrl?: string; error?: string }> {
290
339
  const resolvedUrl = platformUrl || getPlatformUrl();
291
340
  const response = await fetch(
292
341
  `${resolvedUrl}/v1/migrations/export/${jobId}/status/`,
293
342
  {
294
- headers: {
295
- "X-Session-Token": token,
296
- "Vellum-Organization-Id": orgId,
297
- },
343
+ headers: await authHeaders(token, platformUrl),
298
344
  },
299
345
  );
300
346
 
@@ -339,7 +385,6 @@ export async function platformDownloadExport(
339
385
  export async function platformImportPreflight(
340
386
  bundleData: Uint8Array<ArrayBuffer>,
341
387
  token: string,
342
- orgId: string,
343
388
  platformUrl?: string,
344
389
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
345
390
  const resolvedUrl = platformUrl || getPlatformUrl();
@@ -348,9 +393,8 @@ export async function platformImportPreflight(
348
393
  {
349
394
  method: "POST",
350
395
  headers: {
396
+ ...(await authHeaders(token, platformUrl)),
351
397
  "Content-Type": "application/octet-stream",
352
- "X-Session-Token": token,
353
- "Vellum-Organization-Id": orgId,
354
398
  },
355
399
  body: new Blob([bundleData]),
356
400
  signal: AbortSignal.timeout(120_000),
@@ -367,19 +411,17 @@ export async function platformImportPreflight(
367
411
  export async function platformImportBundle(
368
412
  bundleData: Uint8Array<ArrayBuffer>,
369
413
  token: string,
370
- orgId: string,
371
414
  platformUrl?: string,
372
415
  ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
373
416
  const resolvedUrl = platformUrl || getPlatformUrl();
374
417
  const response = await fetch(`${resolvedUrl}/v1/migrations/import/`, {
375
418
  method: "POST",
376
419
  headers: {
420
+ ...(await authHeaders(token, platformUrl)),
377
421
  "Content-Type": "application/octet-stream",
378
- "X-Session-Token": token,
379
- "Vellum-Organization-Id": orgId,
380
422
  },
381
423
  body: new Blob([bundleData]),
382
- signal: AbortSignal.timeout(120_000),
424
+ signal: AbortSignal.timeout(300_000),
383
425
  });
384
426
 
385
427
  const body = (await response.json().catch(() => ({}))) as Record<
@@ -388,3 +430,116 @@ export async function platformImportBundle(
388
430
  >;
389
431
  return { statusCode: response.status, body };
390
432
  }
433
+
434
+ // ---------------------------------------------------------------------------
435
+ // Signed-URL upload flow
436
+ // ---------------------------------------------------------------------------
437
+
438
+ export async function platformRequestUploadUrl(
439
+ token: string,
440
+ platformUrl?: string,
441
+ ): Promise<{ uploadUrl: string; bundleKey: string; expiresAt: string }> {
442
+ const resolvedUrl = platformUrl || getPlatformUrl();
443
+ const response = await fetch(`${resolvedUrl}/v1/migrations/upload-url/`, {
444
+ method: "POST",
445
+ headers: await authHeaders(token, platformUrl),
446
+ body: JSON.stringify({ content_type: "application/octet-stream" }),
447
+ });
448
+
449
+ if (response.status === 201) {
450
+ const body = (await response.json()) as {
451
+ upload_url: string;
452
+ bundle_key: string;
453
+ expires_at: string;
454
+ };
455
+ return {
456
+ uploadUrl: body.upload_url,
457
+ bundleKey: body.bundle_key,
458
+ expiresAt: body.expires_at,
459
+ };
460
+ }
461
+
462
+ if (response.status === 404 || response.status === 503) {
463
+ throw new Error(
464
+ "Signed uploads are not available on this platform instance",
465
+ );
466
+ }
467
+
468
+ const errorBody = (await response.json().catch(() => ({}))) as {
469
+ detail?: string;
470
+ };
471
+ throw new Error(
472
+ errorBody.detail ??
473
+ `Failed to request upload URL: ${response.status} ${response.statusText}`,
474
+ );
475
+ }
476
+
477
+ export async function platformUploadToSignedUrl(
478
+ uploadUrl: string,
479
+ bundleData: Uint8Array<ArrayBuffer>,
480
+ ): Promise<void> {
481
+ const response = await fetch(uploadUrl, {
482
+ method: "PUT",
483
+ headers: {
484
+ "Content-Type": "application/octet-stream",
485
+ },
486
+ body: new Blob([bundleData]),
487
+ signal: AbortSignal.timeout(600_000),
488
+ });
489
+
490
+ if (!response.ok) {
491
+ throw new Error(
492
+ `Upload to signed URL failed: ${response.status} ${response.statusText}`,
493
+ );
494
+ }
495
+ }
496
+
497
+ export async function platformImportPreflightFromGcs(
498
+ bundleKey: string,
499
+ token: string,
500
+ platformUrl?: string,
501
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
502
+ const resolvedUrl = platformUrl || getPlatformUrl();
503
+ const response = await fetch(
504
+ `${resolvedUrl}/v1/migrations/import-preflight-from-gcs/`,
505
+ {
506
+ method: "POST",
507
+ headers: await authHeaders(token, platformUrl),
508
+ signal: AbortSignal.timeout(120_000),
509
+ body: JSON.stringify({ bundle_key: bundleKey }),
510
+ },
511
+ );
512
+
513
+ const body = (await response.json().catch(() => ({}))) as Record<
514
+ string,
515
+ unknown
516
+ >;
517
+ return { statusCode: response.status, body };
518
+ }
519
+
520
+ export async function platformImportBundleFromGcs(
521
+ bundleKey: string,
522
+ token: string,
523
+ platformUrl?: string,
524
+ ): Promise<{ statusCode: number; body: Record<string, unknown> }> {
525
+ const resolvedUrl = platformUrl || getPlatformUrl();
526
+ const response = await fetch(
527
+ `${resolvedUrl}/v1/migrations/import-from-gcs/`,
528
+ {
529
+ method: "POST",
530
+ headers: await authHeaders(token, platformUrl),
531
+ body: JSON.stringify({ bundle_key: bundleKey }),
532
+ signal: AbortSignal.timeout(300_000),
533
+ },
534
+ );
535
+
536
+ if (response.status === 413) {
537
+ throw new Error("Bundle too large to import");
538
+ }
539
+
540
+ const body = (await response.json().catch(() => ({}))) as Record<
541
+ string,
542
+ unknown
543
+ >;
544
+ return { statusCode: response.status, body };
545
+ }
@@ -16,7 +16,7 @@ import { loadGuardianToken } from "./guardian-token.js";
16
16
  import { getPlatformUrl } from "./platform-client.js";
17
17
  import { resolveImageRefs } from "./platform-releases.js";
18
18
  import { exec, execOutput } from "./step-runner.js";
19
- import { parseVersion } from "./version-compat.js";
19
+ import { compareVersions } from "./version-compat.js";
20
20
 
21
21
  // ---------------------------------------------------------------------------
22
22
  // Shared constants & builders for upgrade / rollback lifecycle events
@@ -90,6 +90,7 @@ export function buildUpgradeCommitMessage(options: {
90
90
  */
91
91
  export const CONTAINER_ENV_EXCLUDE_KEYS: ReadonlySet<string> = new Set([
92
92
  "CES_SERVICE_TOKEN",
93
+ "GUARDIAN_BOOTSTRAP_SECRET",
93
94
  "VELLUM_ASSISTANT_NAME",
94
95
  "RUNTIME_HTTP_HOST",
95
96
  "PATH",
@@ -317,26 +318,16 @@ export async function performDockerRollback(
317
318
 
318
319
  // Validate target version < current version
319
320
  if (currentVersion) {
320
- const current = parseVersion(currentVersion);
321
- const target = parseVersion(targetVersion);
322
- if (current && target) {
323
- const isNewer = (() => {
324
- if (target.major !== current.major) return target.major > current.major;
325
- if (target.minor !== current.minor) return target.minor > current.minor;
326
- return target.patch > current.patch;
327
- })();
328
- if (isNewer) {
321
+ const cmp = compareVersions(targetVersion, currentVersion);
322
+ if (cmp !== null) {
323
+ if (cmp > 0) {
329
324
  const msg =
330
325
  "Cannot roll back to a newer version. Use `vellum upgrade` instead.";
331
326
  console.error(msg);
332
327
  emitCliError("VERSION_DIRECTION", msg);
333
328
  process.exit(1);
334
329
  }
335
- const isSame =
336
- target.major === current.major &&
337
- target.minor === current.minor &&
338
- target.patch === current.patch;
339
- if (isSame) {
330
+ if (cmp === 0) {
340
331
  const msg = `Already on version ${targetVersion}. Nothing to roll back to.`;
341
332
  console.error(msg);
342
333
  emitCliError("VERSION_DIRECTION", msg);
@@ -467,6 +458,11 @@ export async function performDockerRollback(
467
458
  ` Captured ${Object.keys(capturedEnv).length} env var(s) from ${res.assistantContainer}\n`,
468
459
  );
469
460
 
461
+ // Capture GUARDIAN_BOOTSTRAP_SECRET from the gateway container (it is only
462
+ // set on gateway, not assistant) so it persists across container restarts.
463
+ const gatewayEnv = await captureContainerEnv(res.gatewayContainer);
464
+ const bootstrapSecret = gatewayEnv["GUARDIAN_BOOTSTRAP_SECRET"];
465
+
470
466
  const cesServiceToken =
471
467
  capturedEnv["CES_SERVICE_TOKEN"] || randomBytes(32).toString("hex");
472
468
 
@@ -575,6 +571,7 @@ export async function performDockerRollback(
575
571
  await startContainers(
576
572
  {
577
573
  signingKey,
574
+ bootstrapSecret,
578
575
  cesServiceToken,
579
576
  extraAssistantEnv,
580
577
  gatewayPort,
@@ -695,6 +692,7 @@ export async function performDockerRollback(
695
692
  await startContainers(
696
693
  {
697
694
  signingKey,
695
+ bootstrapSecret,
698
696
  cesServiceToken,
699
697
  extraAssistantEnv,
700
698
  gatewayPort,
@@ -1,13 +1,16 @@
1
1
  /**
2
- * Parse a version string into { major, minor, patch } components.
3
- * Handles optional `v` prefix (e.g., "v1.2.3" or "1.2.3").
2
+ * Parse a version string into { major, minor, patch, pre } components.
3
+ * Handles optional `v`/`V` prefix (e.g., "v1.2.3" or "1.2.3").
4
+ * Pre-release suffixes are captured (e.g., "0.6.0-staging.5" → pre: "staging.5").
4
5
  * Returns null if the string cannot be parsed as semver.
5
6
  */
6
7
  export function parseVersion(
7
8
  version: string,
8
- ): { major: number; minor: number; patch: number } | null {
9
+ ): { major: number; minor: number; patch: number; pre: string | null } | null {
9
10
  const stripped = version.replace(/^[vV]/, "");
10
- const segments = stripped.split(".");
11
+ const [core, ...rest] = stripped.split("-");
12
+ const pre = rest.length > 0 ? rest.join("-") : null;
13
+ const segments = (core ?? "").split(".");
11
14
 
12
15
  if (segments.length < 2) {
13
16
  return null;
@@ -21,7 +24,66 @@ export function parseVersion(
21
24
  return null;
22
25
  }
23
26
 
24
- return { major, minor, patch };
27
+ return { major, minor, patch, pre };
28
+ }
29
+
30
+ /**
31
+ * Compare two pre-release strings per semver §11:
32
+ * - Dot-separated identifiers compared left to right.
33
+ * - Both numeric → compare as integers.
34
+ * - Both non-numeric → compare lexically.
35
+ * - Numeric vs non-numeric → numeric sorts lower (§11.4.4).
36
+ * - Fewer identifiers sorts earlier when all preceding are equal.
37
+ */
38
+ function comparePreRelease(a: string, b: string): number {
39
+ const pa = a.split(".");
40
+ const pb = b.split(".");
41
+ const len = Math.max(pa.length, pb.length);
42
+ for (let i = 0; i < len; i++) {
43
+ if (i >= pa.length) return -1; // a has fewer fields → a < b
44
+ if (i >= pb.length) return 1;
45
+ const aIsNum = /^\d+$/.test(pa[i]);
46
+ const bIsNum = /^\d+$/.test(pb[i]);
47
+ if (aIsNum && bIsNum) {
48
+ const diff = Number(pa[i]) - Number(pb[i]);
49
+ if (diff !== 0) return diff;
50
+ } else if (aIsNum !== bIsNum) {
51
+ return aIsNum ? -1 : 1; // numeric < non-numeric per §11.4.4
52
+ } else {
53
+ const cmp = (pa[i] ?? "").localeCompare(pb[i] ?? "");
54
+ if (cmp !== 0) return cmp;
55
+ }
56
+ }
57
+ return 0;
58
+ }
59
+
60
+ /**
61
+ * Compare two semver version strings.
62
+ * Returns negative if a < b, 0 if equal, positive if a > b.
63
+ *
64
+ * Handles pre-release suffixes per semver spec:
65
+ * - `0.6.0-staging.1 < 0.6.0` (pre-release < release)
66
+ * - `0.6.0-staging.1 < 0.6.0-staging.2` (numeric postfix comparison)
67
+ *
68
+ * Returns null if either version cannot be parsed.
69
+ */
70
+ export function compareVersions(a: string, b: string): number | null {
71
+ const pa = parseVersion(a);
72
+ const pb = parseVersion(b);
73
+ if (pa === null || pb === null) return null;
74
+
75
+ const majorDiff = pa.major - pb.major;
76
+ if (majorDiff !== 0) return majorDiff;
77
+ const minorDiff = pa.minor - pb.minor;
78
+ if (minorDiff !== 0) return minorDiff;
79
+ const patchDiff = pa.patch - pb.patch;
80
+ if (patchDiff !== 0) return patchDiff;
81
+
82
+ // Same major.minor.patch — compare pre-release
83
+ if (pa.pre === null && pb.pre === null) return 0;
84
+ if (pa.pre !== null && pb.pre === null) return -1; // pre-release < release
85
+ if (pa.pre === null && pb.pre !== null) return 1;
86
+ return comparePreRelease(pa.pre!, pb.pre!);
25
87
  }
26
88
 
27
89
  /**
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Provider API key environment variable names, keyed by provider ID.
3
+ *
4
+ * Keep in sync with:
5
+ * - assistant/src/shared/provider-env-vars.ts
6
+ * - meta/provider-env-vars.json (consumed by the macOS client build)
7
+ *
8
+ * Once a consolidated shared package exists in packages/, all three
9
+ * copies can be replaced by a single import.
10
+ */
11
+ export const PROVIDER_ENV_VAR_NAMES: Record<string, string> = {
12
+ anthropic: "ANTHROPIC_API_KEY",
13
+ openai: "OPENAI_API_KEY",
14
+ gemini: "GEMINI_API_KEY",
15
+ fireworks: "FIREWORKS_API_KEY",
16
+ openrouter: "OPENROUTER_API_KEY",
17
+ brave: "BRAVE_API_KEY",
18
+ perplexity: "PERPLEXITY_API_KEY",
19
+ };