feat(secrets): expand onboarding secret-ref flows and custom-provider parity

This commit is contained in:
joshavant
2026-02-24 22:26:33 -06:00
committed by Peter Steinberger
parent e8637c79b3
commit 5e3a86fd2f
23 changed files with 857 additions and 417 deletions

View File

@@ -2,12 +2,18 @@ import { DEFAULT_PROVIDER } from "../agents/defaults.js";
import { buildModelAliasIndex, modelKey } from "../agents/model-selection.js";
import type { OpenClawConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.models.js";
import { isSecretRef, type SecretInput } from "../config/types.secrets.js";
import type { RuntimeEnv } from "../runtime.js";
import { fetchWithTimeout } from "../utils/fetch-timeout.js";
import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js";
import {
normalizeSecretInput,
normalizeOptionalSecretInput,
} from "../utils/normalize-secret-input.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { ensureApiKeyFromEnvOrPrompt } from "./auth-choice.apply-helpers.js";
import { applyPrimaryModel } from "./model-picker.js";
import { normalizeAlias } from "./models/shared.js";
import type { SecretInputMode } from "./onboard-types.js";
const DEFAULT_OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
const DEFAULT_CONTEXT_WINDOW = 4096;
@@ -63,7 +69,7 @@ export type ApplyCustomApiConfigParams = {
baseUrl: string;
modelId: string;
compatibility: CustomApiCompatibility;
apiKey?: string;
apiKey?: SecretInput;
providerId?: string;
alias?: string;
};
@@ -246,6 +252,13 @@ type VerificationResult = {
error?: unknown;
};
function normalizeOptionalProviderApiKey(value: unknown): SecretInput | undefined {
if (isSecretRef(value)) {
return value;
}
return normalizeOptionalSecretInput(value);
}
function resolveVerificationEndpoint(params: {
baseUrl: string;
modelId: string;
@@ -338,8 +351,10 @@ async function requestAnthropicVerification(params: {
async function promptBaseUrlAndKey(params: {
prompter: WizardPrompter;
config: OpenClawConfig;
secretInputMode?: SecretInputMode;
initialBaseUrl?: string;
}): Promise<{ baseUrl: string; apiKey: string }> {
}): Promise<{ baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string }> {
const baseUrlInput = await params.prompter.text({
message: "API Base URL",
initialValue: params.initialBaseUrl ?? DEFAULT_OLLAMA_BASE_URL,
@@ -353,12 +368,27 @@ async function promptBaseUrlAndKey(params: {
}
},
});
const apiKeyInput = await params.prompter.text({
message: "API Key (leave blank if not required)",
placeholder: "sk-...",
initialValue: "",
const baseUrl = baseUrlInput.trim();
const providerHint = buildEndpointIdFromUrl(baseUrl) || "custom";
let apiKeyInput: SecretInput | undefined;
const resolvedApiKey = await ensureApiKeyFromEnvOrPrompt({
config: params.config,
provider: providerHint,
envLabel: "CUSTOM_API_KEY",
promptMessage: "API Key (leave blank if not required)",
normalize: normalizeSecretInput,
validate: () => undefined,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
setCredential: async (apiKey) => {
apiKeyInput = apiKey;
},
});
return { baseUrl: baseUrlInput.trim(), apiKey: apiKeyInput.trim() };
return {
baseUrl,
apiKey: normalizeOptionalProviderApiKey(apiKeyInput),
resolvedApiKey: normalizeSecretInput(resolvedApiKey),
};
}
type CustomApiRetryChoice = "baseUrl" | "model" | "both";
@@ -386,22 +416,27 @@ async function promptCustomApiModelId(prompter: WizardPrompter): Promise<string>
async function applyCustomApiRetryChoice(params: {
prompter: WizardPrompter;
config: OpenClawConfig;
secretInputMode?: SecretInputMode;
retryChoice: CustomApiRetryChoice;
current: { baseUrl: string; apiKey: string; modelId: string };
}): Promise<{ baseUrl: string; apiKey: string; modelId: string }> {
let { baseUrl, apiKey, modelId } = params.current;
current: { baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string; modelId: string };
}): Promise<{ baseUrl: string; apiKey?: SecretInput; resolvedApiKey: string; modelId: string }> {
let { baseUrl, apiKey, resolvedApiKey, modelId } = params.current;
if (params.retryChoice === "baseUrl" || params.retryChoice === "both") {
const retryInput = await promptBaseUrlAndKey({
prompter: params.prompter,
config: params.config,
secretInputMode: params.secretInputMode,
initialBaseUrl: baseUrl,
});
baseUrl = retryInput.baseUrl;
apiKey = retryInput.apiKey;
resolvedApiKey = retryInput.resolvedApiKey;
}
if (params.retryChoice === "model" || params.retryChoice === "both") {
modelId = await promptCustomApiModelId(params.prompter);
}
return { baseUrl, apiKey, modelId };
return { baseUrl, apiKey, resolvedApiKey, modelId };
}
function resolveProviderApi(
@@ -542,7 +577,8 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom
const mergedModels = hasModel ? existingModels : [...existingModels, nextModel];
const { apiKey: existingApiKey, ...existingProviderRest } = existingProvider ?? {};
const normalizedApiKey =
normalizeOptionalSecretInput(params.apiKey) ?? normalizeOptionalSecretInput(existingApiKey);
normalizeOptionalProviderApiKey(params.apiKey) ??
normalizeOptionalProviderApiKey(existingApiKey);
let config: OpenClawConfig = {
...params.config,
@@ -596,12 +632,18 @@ export async function promptCustomApiConfig(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
config: OpenClawConfig;
secretInputMode?: SecretInputMode;
}): Promise<CustomApiResult> {
const { prompter, runtime, config } = params;
const baseInput = await promptBaseUrlAndKey({ prompter });
const baseInput = await promptBaseUrlAndKey({
prompter,
config,
secretInputMode: params.secretInputMode,
});
let baseUrl = baseInput.baseUrl;
let apiKey = baseInput.apiKey;
let resolvedApiKey = baseInput.resolvedApiKey;
const compatibilityChoice = await prompter.select({
message: "Endpoint compatibility",
@@ -621,13 +663,21 @@ export async function promptCustomApiConfig(params: {
let verifiedFromProbe = false;
if (!compatibility) {
const probeSpinner = prompter.progress("Detecting endpoint type...");
const openaiProbe = await requestOpenAiVerification({ baseUrl, apiKey, modelId });
const openaiProbe = await requestOpenAiVerification({
baseUrl,
apiKey: resolvedApiKey,
modelId,
});
if (openaiProbe.ok) {
probeSpinner.stop("Detected OpenAI-compatible endpoint.");
compatibility = "openai";
verifiedFromProbe = true;
} else {
const anthropicProbe = await requestAnthropicVerification({ baseUrl, apiKey, modelId });
const anthropicProbe = await requestAnthropicVerification({
baseUrl,
apiKey: resolvedApiKey,
modelId,
});
if (anthropicProbe.ok) {
probeSpinner.stop("Detected Anthropic-compatible endpoint.");
compatibility = "anthropic";
@@ -639,10 +689,12 @@ export async function promptCustomApiConfig(params: {
"Endpoint detection",
);
const retryChoice = await promptCustomApiRetryChoice(prompter);
({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({
({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({
prompter,
config,
secretInputMode: params.secretInputMode,
retryChoice,
current: { baseUrl, apiKey, modelId },
current: { baseUrl, apiKey, resolvedApiKey, modelId },
}));
continue;
}
@@ -656,8 +708,8 @@ export async function promptCustomApiConfig(params: {
const verifySpinner = prompter.progress("Verifying...");
const result =
compatibility === "anthropic"
? await requestAnthropicVerification({ baseUrl, apiKey, modelId })
: await requestOpenAiVerification({ baseUrl, apiKey, modelId });
? await requestAnthropicVerification({ baseUrl, apiKey: resolvedApiKey, modelId })
: await requestOpenAiVerification({ baseUrl, apiKey: resolvedApiKey, modelId });
if (result.ok) {
verifySpinner.stop("Verification successful.");
break;
@@ -668,10 +720,12 @@ export async function promptCustomApiConfig(params: {
verifySpinner.stop(`Verification failed: ${formatVerificationError(result.error)}`);
}
const retryChoice = await promptCustomApiRetryChoice(prompter);
({ baseUrl, apiKey, modelId } = await applyCustomApiRetryChoice({
({ baseUrl, apiKey, resolvedApiKey, modelId } = await applyCustomApiRetryChoice({
prompter,
config,
secretInputMode: params.secretInputMode,
retryChoice,
current: { baseUrl, apiKey, modelId },
current: { baseUrl, apiKey, resolvedApiKey, modelId },
}));
if (compatibilityChoice === "unknown") {
compatibility = null;