@vellumai/cli 0.8.2 → 0.8.4

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,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);
@@ -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. */