mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
feat(secrets): expand onboarding secret-ref flows and custom-provider parity
This commit is contained in:
committed by
Peter Steinberger
parent
e8637c79b3
commit
5e3a86fd2f
@@ -49,7 +49,23 @@ openclaw onboard --non-interactive \
|
||||
--accept-risk
|
||||
```
|
||||
|
||||
With `--secret-input-mode ref`, onboarding writes provider default env refs (for example `OPENAI_API_KEY`) into auth profiles instead of plaintext key values.
|
||||
With `--secret-input-mode ref`, onboarding writes env-backed refs instead of plaintext key values.
|
||||
For auth-profile backed providers this writes `keyRef` entries; for custom providers this writes `models.providers.<id>.apiKey` as an env ref (for example `{ source: "env", id: "CUSTOM_API_KEY" }`).
|
||||
|
||||
Non-interactive `ref` mode contract:
|
||||
|
||||
- Set the provider env var in the onboarding process environment (for example `OPENAI_API_KEY`).
|
||||
- Do not pass inline key flags (for example `--openai-api-key`) unless that env var is also set.
|
||||
- If an inline key flag is passed without the required env var, onboarding fails fast with guidance.
|
||||
|
||||
Interactive onboarding behavior with reference mode:
|
||||
|
||||
- Choose **Use secret reference** when prompted.
|
||||
- Then choose either:
|
||||
- Environment variable
|
||||
- Encrypted `sops` file (JSON pointer)
|
||||
- Onboarding performs a fast preflight validation before saving the ref.
|
||||
- If validation fails, onboarding shows the error and lets you retry.
|
||||
|
||||
Non-interactive Z.AI endpoint choices:
|
||||
|
||||
|
||||
@@ -24,6 +24,15 @@ Secrets are resolved into an in-memory runtime snapshot.
|
||||
|
||||
This keeps external secret source outages off the hot request path.
|
||||
|
||||
## Onboarding reference preflight
|
||||
|
||||
When onboarding runs in interactive mode and you choose secret reference storage, OpenClaw performs a fast preflight check before saving:
|
||||
|
||||
- Env refs: validates env var name and confirms a non-empty value is visible during onboarding.
|
||||
- File refs (`sops`): validates `secrets.sources.file`, decrypts, and resolves the JSON pointer.
|
||||
|
||||
If validation fails, onboarding shows the error and lets you retry with a different ref/source.
|
||||
|
||||
## SecretRef contract
|
||||
|
||||
Use one object shape everywhere:
|
||||
|
||||
@@ -33,6 +33,10 @@ openclaw onboard --non-interactive \
|
||||
Add `--json` for a machine-readable summary.
|
||||
|
||||
Use `--secret-input-mode ref` to store env-backed refs in auth profiles instead of plaintext values.
|
||||
Interactive selection between env refs and encrypted file refs (`sops`) is available in the onboarding wizard flow.
|
||||
|
||||
In non-interactive `ref` mode, provider env vars must be set in the process environment.
|
||||
Passing inline key flags without the matching env var now fails fast.
|
||||
|
||||
Example:
|
||||
|
||||
@@ -145,6 +149,24 @@ openclaw onboard --non-interactive \
|
||||
|
||||
`--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||
|
||||
Ref-mode variant:
|
||||
|
||||
```bash
|
||||
export CUSTOM_API_KEY="your-key"
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice custom-api-key \
|
||||
--custom-base-url "https://llm.example.com/v1" \
|
||||
--custom-model-id "foo-large" \
|
||||
--secret-input-mode ref \
|
||||
--custom-provider-id "my-custom" \
|
||||
--custom-compatibility anthropic \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
In this mode, onboarding stores `apiKey` as `{ source: "env", id: "CUSTOM_API_KEY" }`.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
|
||||
@@ -177,6 +177,10 @@ What you set:
|
||||
<Accordion title="Custom provider">
|
||||
Works with OpenAI-compatible and Anthropic-compatible endpoints.
|
||||
|
||||
Interactive onboarding supports the same API key storage choices as other provider API key flows:
|
||||
- **Paste API key now** (plaintext)
|
||||
- **Use secret reference** (env or encrypted `sops` file pointer, with preflight validation)
|
||||
|
||||
Non-interactive flags:
|
||||
- `--auth-choice custom-api-key`
|
||||
- `--custom-base-url`
|
||||
@@ -204,7 +208,19 @@ Credential and profile paths:
|
||||
API key storage mode:
|
||||
|
||||
- Default onboarding behavior persists API keys as plaintext values in auth profiles.
|
||||
- `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext values (for example `keyRef: { source: "env", id: "OPENAI_API_KEY" }`).
|
||||
- `--secret-input-mode ref` enables reference mode instead of plaintext key storage.
|
||||
In interactive onboarding, you can choose either:
|
||||
- environment variable ref (for example `keyRef: { source: "env", id: "OPENAI_API_KEY" }`)
|
||||
- encrypted file ref via `sops` JSON pointer (for example `keyRef: { source: "file", id: "/providers/openai/apiKey" }`)
|
||||
- Interactive reference mode runs a fast preflight validation before saving.
|
||||
- Env refs: validates variable name + non-empty value in the current onboarding environment.
|
||||
- File refs: validates `secrets.sources.file` + `sops` decrypt + JSON pointer resolution.
|
||||
- If preflight fails, onboarding shows the error and lets you retry.
|
||||
- In non-interactive mode, `--secret-input-mode ref` is env-backed only.
|
||||
- Set the provider env var in the onboarding process environment.
|
||||
- Inline key flags (for example `--openai-api-key`) require that env var to be set; otherwise onboarding fails fast.
|
||||
- For custom providers, non-interactive `ref` mode stores `models.providers.<id>.apiKey` as `{ source: "env", id: "CUSTOM_API_KEY" }`.
|
||||
- In that custom-provider case, `--custom-api-key` requires `CUSTOM_API_KEY` to be set; otherwise onboarding fails fast.
|
||||
- Existing plaintext setups continue to work unchanged.
|
||||
|
||||
<Note>
|
||||
|
||||
@@ -66,6 +66,8 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
|
||||
1. **Model/Auth** — Anthropic API key (recommended), OpenAI, or Custom Provider
|
||||
(OpenAI-compatible, Anthropic-compatible, or Unknown auto-detect). Pick a default model.
|
||||
For non-interactive runs, `--secret-input-mode ref` stores env-backed refs in auth profiles instead of plaintext API key values.
|
||||
In non-interactive `ref` mode, the provider env var must be set; passing inline key flags without that env var fails fast.
|
||||
In interactive runs, choosing secret reference mode lets you point at either an environment variable or an encrypted `sops` file pointer, with a fast preflight validation before saving.
|
||||
2. **Workspace** — Location for agent files (default `~/.openclaw/workspace`). Seeds bootstrap files.
|
||||
3. **Gateway** — Port, bind address, auth mode, Tailscale exposure.
|
||||
4. **Channels** — WhatsApp, Telegram, Discord, Google Chat, Mattermost, Signal, BlueBubbles, or iMessage.
|
||||
|
||||
@@ -26,11 +26,13 @@ function restoreMinimaxEnv(): void {
|
||||
function createPrompter(params?: {
|
||||
confirm?: WizardPrompter["confirm"];
|
||||
note?: WizardPrompter["note"];
|
||||
select?: WizardPrompter["select"];
|
||||
text?: WizardPrompter["text"];
|
||||
}): WizardPrompter {
|
||||
return {
|
||||
confirm: params?.confirm ?? (vi.fn(async () => true) as WizardPrompter["confirm"]),
|
||||
note: params?.note ?? (vi.fn(async () => undefined) as WizardPrompter["note"]),
|
||||
...(params?.select ? { select: params.select } : {}),
|
||||
text: params?.text ?? (vi.fn(async () => "prompt-key") as WizardPrompter["text"]),
|
||||
} as unknown as WizardPrompter;
|
||||
}
|
||||
@@ -53,6 +55,7 @@ async function runEnsureMinimaxApiKeyFlow(params: { confirmResult: boolean; text
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
|
||||
const result = await ensureApiKeyFromEnvOrPrompt({
|
||||
config: {},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
@@ -143,7 +146,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe("prompted-key");
|
||||
expect(setCredential).toHaveBeenCalledWith("prompted-key", undefined);
|
||||
expect(setCredential).toHaveBeenCalledWith("prompted-key", "plaintext");
|
||||
expect(text).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Enter key",
|
||||
@@ -162,6 +165,7 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
|
||||
const result = await ensureApiKeyFromEnvOrPrompt({
|
||||
config: {},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
@@ -173,38 +177,69 @@ describe("ensureApiKeyFromEnvOrPrompt", () => {
|
||||
});
|
||||
|
||||
expect(result).toBe("env-key");
|
||||
expect(setCredential).toHaveBeenCalledWith("${MINIMAX_API_KEY}", "ref");
|
||||
expect(setCredential).toHaveBeenCalledWith({ source: "env", id: "MINIMAX_API_KEY" }, "ref");
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a ref-mode note when plaintext input is provided in ref mode", async () => {
|
||||
delete process.env.MINIMAX_API_KEY;
|
||||
it("re-prompts after sops ref validation failure and succeeds with env ref", async () => {
|
||||
process.env.MINIMAX_API_KEY = "env-key";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
|
||||
const { confirm, note, text } = createPromptSpies({
|
||||
confirmResult: false,
|
||||
textResult: " prompted-key ",
|
||||
});
|
||||
const selectValues: Array<"file" | "env"> = ["file", "env"];
|
||||
const select = vi.fn(async () => selectValues.shift() ?? "env") as WizardPrompter["select"];
|
||||
const text = vi
|
||||
.fn<WizardPrompter["text"]>()
|
||||
.mockResolvedValueOnce("/providers/minimax/apiKey")
|
||||
.mockResolvedValueOnce("MINIMAX_API_KEY");
|
||||
const note = vi.fn(async () => undefined);
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
|
||||
const result = await ensureApiKeyFromEnvOrPrompt({
|
||||
config: {},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
normalize: (value) => value.trim(),
|
||||
validate: () => undefined,
|
||||
prompter: createPrompter({ confirm, note, text }),
|
||||
prompter: createPrompter({ select, text, note }),
|
||||
secretInputMode: "ref",
|
||||
setCredential,
|
||||
});
|
||||
|
||||
expect(result).toBe("prompted-key");
|
||||
expect(setCredential).toHaveBeenCalledWith("prompted-key", "ref");
|
||||
expect(result).toBe("env-key");
|
||||
expect(setCredential).toHaveBeenCalledWith({ source: "env", id: "MINIMAX_API_KEY" }, "ref");
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-input-mode=ref stores an env reference"),
|
||||
"Ref mode note",
|
||||
expect.stringContaining("Could not validate this encrypted file reference."),
|
||||
"Reference check failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("never includes resolved env secret values in reference validation notes", async () => {
|
||||
process.env.MINIMAX_API_KEY = "sk-minimax-redacted-value";
|
||||
delete process.env.MINIMAX_OAUTH_TOKEN;
|
||||
|
||||
const select = vi.fn(async () => "env") as WizardPrompter["select"];
|
||||
const text = vi.fn<WizardPrompter["text"]>().mockResolvedValue("MINIMAX_API_KEY");
|
||||
const note = vi.fn(async () => undefined);
|
||||
const setCredential = vi.fn(async () => undefined);
|
||||
|
||||
const result = await ensureApiKeyFromEnvOrPrompt({
|
||||
config: {},
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
promptMessage: "Enter key",
|
||||
normalize: (value) => value.trim(),
|
||||
validate: () => undefined,
|
||||
prompter: createPrompter({ select, text, note }),
|
||||
secretInputMode: "ref",
|
||||
setCredential,
|
||||
});
|
||||
|
||||
expect(result).toBe("sk-minimax-redacted-value");
|
||||
const noteMessages = note.mock.calls.map((call) => String(call.at(0) ?? "")).join("\n");
|
||||
expect(noteMessages).toContain("Validated environment variable MINIMAX_API_KEY.");
|
||||
expect(noteMessages).not.toContain("sk-minimax-redacted-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureApiKeyFromOptionEnvOrPrompt", () => {
|
||||
@@ -218,6 +253,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => {
|
||||
const result = await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: " opts-key ",
|
||||
tokenProvider: " HUGGINGFACE ",
|
||||
config: {},
|
||||
expectedProviders: ["huggingface"],
|
||||
provider: "huggingface",
|
||||
envLabel: "HF_TOKEN",
|
||||
@@ -250,6 +286,7 @@ describe("ensureApiKeyFromOptionEnvOrPrompt", () => {
|
||||
const result = await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: "opts-key",
|
||||
tokenProvider: "openai",
|
||||
config: {},
|
||||
expectedProviders: ["minimax"],
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
|
||||
@@ -1,18 +1,182 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import type { SecretInput, SecretRef } from "../config/types.secrets.js";
|
||||
import { encodeJsonPointerToken } from "../secrets/json-pointer.js";
|
||||
import { PROVIDER_ENV_VARS } from "../secrets/provider-env-vars.js";
|
||||
import { resolveSecretRefString } from "../secrets/resolve.js";
|
||||
import type { WizardPrompter } from "../wizard/prompts.js";
|
||||
import { formatApiKeyPreview } from "./auth-choice.api-key.js";
|
||||
import type { ApplyAuthChoiceParams } from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
import type { SecretInputMode } from "./onboard-types.js";
|
||||
|
||||
const INLINE_ENV_REF_RE = /^\$\{([A-Z][A-Z0-9_]*)\}$/;
|
||||
const ENV_SOURCE_LABEL_RE = /(?:^|:\s)([A-Z][A-Z0-9_]*)$/;
|
||||
const ENV_SECRET_REF_ID_RE = /^[A-Z][A-Z0-9_]{0,127}$/;
|
||||
const FILE_SECRET_REF_SEGMENT_RE = /^(?:[^~]|~0|~1)*$/;
|
||||
|
||||
type SecretRefSourceChoice = "env" | "file";
|
||||
|
||||
function isValidFileSecretRefId(value: string): boolean {
|
||||
if (!value.startsWith("/")) {
|
||||
return false;
|
||||
}
|
||||
return value
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.every((segment) => FILE_SECRET_REF_SEGMENT_RE.test(segment));
|
||||
}
|
||||
|
||||
function formatErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error && typeof error.message === "string" && error.message.trim()) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function extractEnvVarFromSourceLabel(source: string): string | undefined {
|
||||
const match = ENV_SOURCE_LABEL_RE.exec(source.trim());
|
||||
return match?.[1];
|
||||
}
|
||||
|
||||
function resolveDefaultProviderEnvVar(provider: string): string | undefined {
|
||||
const envVars = PROVIDER_ENV_VARS[provider];
|
||||
return envVars?.find((candidate) => candidate.trim().length > 0);
|
||||
}
|
||||
|
||||
function resolveDefaultSopsPointerId(provider: string): string {
|
||||
return `/providers/${encodeJsonPointerToken(provider)}/apiKey`;
|
||||
}
|
||||
|
||||
function resolveRefFallbackInput(params: {
|
||||
provider: string;
|
||||
preferredEnvVar?: string;
|
||||
envKeyValue?: string;
|
||||
}): { input: SecretInput; resolvedValue: string } {
|
||||
const fallbackEnvVar = params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider);
|
||||
if (fallbackEnvVar) {
|
||||
const value = process.env[fallbackEnvVar]?.trim();
|
||||
if (value) {
|
||||
return {
|
||||
input: { source: "env", id: fallbackEnvVar },
|
||||
resolvedValue: value,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (params.envKeyValue?.trim()) {
|
||||
return {
|
||||
input: params.envKeyValue.trim(),
|
||||
resolvedValue: params.envKeyValue.trim(),
|
||||
};
|
||||
}
|
||||
throw new Error(
|
||||
`No environment variable found for provider "${params.provider}". Re-run onboarding in an interactive terminal to set a secret reference.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveApiKeyRefForOnboarding(params: {
|
||||
provider: string;
|
||||
config: OpenClawConfig;
|
||||
prompter: WizardPrompter;
|
||||
preferredEnvVar?: string;
|
||||
}): Promise<{ ref: SecretRef; resolvedValue: string }> {
|
||||
const defaultEnvVar =
|
||||
params.preferredEnvVar ?? resolveDefaultProviderEnvVar(params.provider) ?? "";
|
||||
const defaultFilePointer = resolveDefaultSopsPointerId(params.provider);
|
||||
let sourceChoice: SecretRefSourceChoice = "env";
|
||||
|
||||
while (true) {
|
||||
const sourceRaw: SecretRefSourceChoice = await params.prompter.select<SecretRefSourceChoice>({
|
||||
message: "Where is this API key stored?",
|
||||
initialValue: sourceChoice,
|
||||
options: [
|
||||
{
|
||||
value: "env",
|
||||
label: "Environment variable",
|
||||
hint: "Reference a variable from your runtime environment",
|
||||
},
|
||||
{
|
||||
value: "file",
|
||||
label: "Encrypted sops file",
|
||||
hint: "Reference a JSON pointer from secrets.sources.file",
|
||||
},
|
||||
],
|
||||
});
|
||||
const source: SecretRefSourceChoice = sourceRaw === "file" ? "file" : "env";
|
||||
sourceChoice = source;
|
||||
|
||||
if (source === "env") {
|
||||
const envVarRaw = await params.prompter.text({
|
||||
message: "Environment variable name",
|
||||
initialValue: defaultEnvVar || undefined,
|
||||
placeholder: "OPENAI_API_KEY",
|
||||
validate: (value) => {
|
||||
const candidate = value.trim();
|
||||
if (!ENV_SECRET_REF_ID_RE.test(candidate)) {
|
||||
return 'Use an env var name like "OPENAI_API_KEY" (uppercase letters, numbers, underscores).';
|
||||
}
|
||||
if (!process.env[candidate]?.trim()) {
|
||||
return `Environment variable "${candidate}" is missing or empty in this session.`;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const envCandidate = String(envVarRaw ?? "").trim();
|
||||
const envVar =
|
||||
envCandidate && ENV_SECRET_REF_ID_RE.test(envCandidate) ? envCandidate : defaultEnvVar;
|
||||
if (!envVar) {
|
||||
throw new Error(
|
||||
`No valid environment variable name provided for provider "${params.provider}".`,
|
||||
);
|
||||
}
|
||||
const ref: SecretRef = { source: "env", id: envVar };
|
||||
const resolvedValue = await resolveSecretRefString(ref, {
|
||||
config: params.config,
|
||||
env: process.env,
|
||||
});
|
||||
await params.prompter.note(
|
||||
`Validated environment variable ${envVar}. OpenClaw will store a reference, not the key value.`,
|
||||
"Reference validated",
|
||||
);
|
||||
return { ref, resolvedValue };
|
||||
}
|
||||
|
||||
const pointerRaw = await params.prompter.text({
|
||||
message: "JSON pointer inside encrypted secrets file",
|
||||
initialValue: defaultFilePointer,
|
||||
placeholder: "/providers/openai/apiKey",
|
||||
validate: (value) => {
|
||||
const candidate = value.trim();
|
||||
if (!isValidFileSecretRefId(candidate)) {
|
||||
return 'Use an absolute JSON pointer like "/providers/openai/apiKey".';
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
const pointer = String(pointerRaw ?? "").trim() || defaultFilePointer;
|
||||
const ref: SecretRef = { source: "file", id: pointer };
|
||||
try {
|
||||
const resolvedValue = await resolveSecretRefString(ref, {
|
||||
config: params.config,
|
||||
env: process.env,
|
||||
});
|
||||
await params.prompter.note(
|
||||
`Validated encrypted file reference ${pointer}. OpenClaw will store a reference, not the key value.`,
|
||||
"Reference validated",
|
||||
);
|
||||
return { ref, resolvedValue };
|
||||
} catch (error) {
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Could not validate this encrypted file reference.",
|
||||
formatErrorMessage(error),
|
||||
"Check secrets.sources.file configuration and sops key access, then try again.",
|
||||
].join("\n"),
|
||||
"Reference check failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthChoiceAgentModelNoter(
|
||||
params: ApplyAuthChoiceParams,
|
||||
): (model: string) => Promise<void> {
|
||||
@@ -111,22 +275,23 @@ export async function resolveSecretInputModeForEnvSelection(params: {
|
||||
if (typeof params.prompter.select !== "function") {
|
||||
return "plaintext";
|
||||
}
|
||||
return await params.prompter.select<SecretInputMode>({
|
||||
message: "How should OpenClaw store this API key?",
|
||||
const selected = await params.prompter.select<SecretInputMode>({
|
||||
message: "How do you want to provide this API key?",
|
||||
initialValue: "plaintext",
|
||||
options: [
|
||||
{
|
||||
value: "plaintext",
|
||||
label: "Plaintext on disk",
|
||||
hint: "Default and fully backward-compatible",
|
||||
label: "Paste API key now",
|
||||
hint: "Stores the key directly in OpenClaw config",
|
||||
},
|
||||
{
|
||||
value: "ref",
|
||||
label: "Env secret reference",
|
||||
hint: "Stores env ref only (no plaintext key in auth-profiles)",
|
||||
label: "Use secret reference",
|
||||
hint: "Stores a reference to env or encrypted sops secrets",
|
||||
},
|
||||
],
|
||||
});
|
||||
return selected === "ref" ? "ref" : "plaintext";
|
||||
}
|
||||
|
||||
export async function maybeApplyApiKeyFromOption(params: {
|
||||
@@ -135,7 +300,7 @@ export async function maybeApplyApiKeyFromOption(params: {
|
||||
secretInputMode?: SecretInputMode;
|
||||
expectedProviders: string[];
|
||||
normalize: (value: string) => string;
|
||||
setCredential: (apiKey: string, mode?: SecretInputMode) => Promise<void>;
|
||||
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
|
||||
}): Promise<string | undefined> {
|
||||
const tokenProvider = normalizeTokenProviderInput(params.tokenProvider);
|
||||
const expectedProviders = params.expectedProviders
|
||||
@@ -153,6 +318,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
|
||||
token: string | undefined;
|
||||
tokenProvider: string | undefined;
|
||||
secretInputMode?: SecretInputMode;
|
||||
config: OpenClawConfig;
|
||||
expectedProviders: string[];
|
||||
provider: string;
|
||||
envLabel: string;
|
||||
@@ -160,7 +326,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
|
||||
normalize: (value: string) => string;
|
||||
validate: (value: string) => string | undefined;
|
||||
prompter: WizardPrompter;
|
||||
setCredential: (apiKey: string, mode?: SecretInputMode) => Promise<void>;
|
||||
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
|
||||
noteMessage?: string;
|
||||
noteTitle?: string;
|
||||
}): Promise<string> {
|
||||
@@ -181,6 +347,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
|
||||
}
|
||||
|
||||
return await ensureApiKeyFromEnvOrPrompt({
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
envLabel: params.envLabel,
|
||||
promptMessage: params.promptMessage,
|
||||
@@ -193,6 +360,7 @@ export async function ensureApiKeyFromOptionEnvOrPrompt(params: {
|
||||
}
|
||||
|
||||
export async function ensureApiKeyFromEnvOrPrompt(params: {
|
||||
config: OpenClawConfig;
|
||||
provider: string;
|
||||
envLabel: string;
|
||||
promptMessage: string;
|
||||
@@ -200,27 +368,41 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
|
||||
validate: (value: string) => string | undefined;
|
||||
prompter: WizardPrompter;
|
||||
secretInputMode?: SecretInputMode;
|
||||
setCredential: (apiKey: string, mode?: SecretInputMode) => Promise<void>;
|
||||
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => Promise<void>;
|
||||
}): Promise<string> {
|
||||
const selectedMode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: params.secretInputMode,
|
||||
});
|
||||
const envKey = resolveEnvApiKey(params.provider);
|
||||
if (envKey) {
|
||||
|
||||
if (selectedMode === "ref") {
|
||||
if (typeof params.prompter.select !== "function") {
|
||||
const fallback = resolveRefFallbackInput({
|
||||
provider: params.provider,
|
||||
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
|
||||
envKeyValue: envKey?.apiKey,
|
||||
});
|
||||
await params.setCredential(fallback.input, selectedMode);
|
||||
return fallback.resolvedValue;
|
||||
}
|
||||
const resolved = await resolveApiKeyRefForOnboarding({
|
||||
provider: params.provider,
|
||||
config: params.config,
|
||||
prompter: params.prompter,
|
||||
preferredEnvVar: envKey?.source ? extractEnvVarFromSourceLabel(envKey.source) : undefined,
|
||||
});
|
||||
await params.setCredential(resolved.ref, selectedMode);
|
||||
return resolved.resolvedValue;
|
||||
}
|
||||
|
||||
if (envKey && selectedMode === "plaintext") {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing ${params.envLabel} (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: params.secretInputMode,
|
||||
});
|
||||
const explicitEnvRef =
|
||||
mode === "ref"
|
||||
? (() => {
|
||||
const envVar = extractEnvVarFromSourceLabel(envKey.source);
|
||||
return envVar ? `\${${envVar}}` : envKey.apiKey;
|
||||
})()
|
||||
: envKey.apiKey;
|
||||
await params.setCredential(explicitEnvRef, mode);
|
||||
await params.setCredential(envKey.apiKey, selectedMode);
|
||||
return envKey.apiKey;
|
||||
}
|
||||
}
|
||||
@@ -230,12 +412,6 @@ export async function ensureApiKeyFromEnvOrPrompt(params: {
|
||||
validate: params.validate,
|
||||
});
|
||||
const apiKey = params.normalize(String(key ?? ""));
|
||||
if (params.secretInputMode === "ref" && !INLINE_ENV_REF_RE.test(apiKey)) {
|
||||
await params.prompter.note(
|
||||
"secret-input-mode=ref stores an env reference, not plaintext key input. Enter ${ENV_VAR} to target a specific variable, or keep current input to use the provider default env var.",
|
||||
"Ref mode note",
|
||||
);
|
||||
}
|
||||
await params.setCredential(apiKey, params.secretInputMode);
|
||||
await params.setCredential(apiKey, selectedMode);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { upsertAuthProfile } from "../agents/auth-profiles.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { buildTokenProfileId, validateAnthropicSetupToken } from "./auth-token.js";
|
||||
@@ -75,39 +71,21 @@ export async function applyAuthChoiceAnthropic(
|
||||
}
|
||||
|
||||
let nextConfig = params.config;
|
||||
let hasCredential = false;
|
||||
const envKey = process.env.ANTHROPIC_API_KEY?.trim();
|
||||
|
||||
if (params.opts?.token) {
|
||||
await setAnthropicApiKey(normalizeApiKeyInput(params.opts.token), params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential && envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing ANTHROPIC_API_KEY (env, ${formatApiKeyPreview(envKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setAnthropicApiKey(envKey, params.agentDir, { secretInputMode: mode });
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Anthropic API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setAnthropicApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
}
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.token,
|
||||
tokenProvider: params.opts?.tokenProvider ?? "anthropic",
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["anthropic"],
|
||||
provider: "anthropic",
|
||||
envLabel: "ANTHROPIC_API_KEY",
|
||||
promptMessage: "Enter Anthropic API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setAnthropicApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
|
||||
});
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "anthropic:default",
|
||||
provider: "anthropic",
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import type { SecretInput } from "../config/types.secrets.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
normalizeSecretInputModeInput,
|
||||
createAuthChoiceAgentModelNoter,
|
||||
createAuthChoiceDefaultModelApplier,
|
||||
createAuthChoiceModelStateBridge,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
normalizeTokenProviderInput,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import { applyAuthChoiceHuggingface } from "./auth-choice.apply.huggingface.js";
|
||||
@@ -128,7 +123,7 @@ type SimpleApiKeyProviderFlow = {
|
||||
envLabel: string;
|
||||
promptMessage: string;
|
||||
setCredential: (
|
||||
apiKey: string,
|
||||
apiKey: SecretInput,
|
||||
agentDir?: string,
|
||||
options?: ApiKeyStorageOptions,
|
||||
) => void | Promise<void>;
|
||||
@@ -363,7 +358,7 @@ export async function applyAuthChoiceApiProviders(
|
||||
expectedProviders: string[];
|
||||
envLabel: string;
|
||||
promptMessage: string;
|
||||
setCredential: (apiKey: string, mode?: SecretInputMode) => void | Promise<void>;
|
||||
setCredential: (apiKey: SecretInput, mode?: SecretInputMode) => void | Promise<void>;
|
||||
defaultModel: string;
|
||||
applyDefaultConfig: (
|
||||
config: ApplyAuthChoiceParams["config"],
|
||||
@@ -383,6 +378,7 @@ export async function applyAuthChoiceApiProviders(
|
||||
provider,
|
||||
tokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders,
|
||||
envLabel,
|
||||
promptMessage,
|
||||
@@ -431,6 +427,7 @@ export async function applyAuthChoiceApiProviders(
|
||||
token: params.opts?.token,
|
||||
tokenProvider: normalizedTokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["litellm"],
|
||||
provider: "litellm",
|
||||
envLabel: "LITELLM_API_KEY",
|
||||
@@ -508,53 +505,26 @@ export async function applyAuthChoiceApiProviders(
|
||||
}
|
||||
};
|
||||
|
||||
const optsApiKey = normalizeApiKeyInput(params.opts?.cloudflareAiGatewayApiKey ?? "");
|
||||
let resolvedApiKey = "";
|
||||
if (accountId && gatewayId && optsApiKey) {
|
||||
await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
resolvedApiKey = optsApiKey;
|
||||
}
|
||||
await ensureAccountGateway();
|
||||
|
||||
const envKey = resolveEnvApiKey("cloudflare-ai-gateway");
|
||||
if (!resolvedApiKey && envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing CLOUDFLARE_AI_GATEWAY_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
await ensureAccountGateway();
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setCloudflareAiGatewayConfig(accountId, gatewayId, envKey.apiKey, params.agentDir, {
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.cloudflareAiGatewayApiKey,
|
||||
tokenProvider: "cloudflare-ai-gateway",
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["cloudflare-ai-gateway"],
|
||||
provider: "cloudflare-ai-gateway",
|
||||
envLabel: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
promptMessage: "Enter Cloudflare AI Gateway API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setCloudflareAiGatewayConfig(accountId, gatewayId, apiKey, params.agentDir, {
|
||||
secretInputMode: mode,
|
||||
});
|
||||
resolvedApiKey = normalizeApiKeyInput(envKey.apiKey);
|
||||
}
|
||||
}
|
||||
}),
|
||||
});
|
||||
|
||||
if (!resolvedApiKey && optsApiKey) {
|
||||
await ensureAccountGateway();
|
||||
await setCloudflareAiGatewayConfig(accountId, gatewayId, optsApiKey, params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
resolvedApiKey = optsApiKey;
|
||||
}
|
||||
|
||||
if (!resolvedApiKey) {
|
||||
await ensureAccountGateway();
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter Cloudflare AI Gateway API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
resolvedApiKey = normalizeApiKeyInput(String(key ?? ""));
|
||||
await setCloudflareAiGatewayConfig(accountId, gatewayId, resolvedApiKey, params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
}
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "cloudflare-ai-gateway:default",
|
||||
provider: "cloudflare-ai-gateway",
|
||||
@@ -583,6 +553,7 @@ export async function applyAuthChoiceApiProviders(
|
||||
provider: "google",
|
||||
tokenProvider: normalizedTokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["google"],
|
||||
envLabel: "GEMINI_API_KEY",
|
||||
promptMessage: "Enter Gemini API key",
|
||||
@@ -627,6 +598,7 @@ export async function applyAuthChoiceApiProviders(
|
||||
provider: "zai",
|
||||
tokenProvider: normalizedTokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["zai"],
|
||||
envLabel: "ZAI_API_KEY",
|
||||
promptMessage: "Enter Z.AI API key",
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyPrimaryModel } from "./model-picker.js";
|
||||
@@ -23,44 +18,20 @@ export async function applyAuthChoiceBytePlus(
|
||||
}
|
||||
|
||||
const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode);
|
||||
const envKey = resolveEnvApiKey("byteplus");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing BYTEPLUS_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setByteplusApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "byteplus:default",
|
||||
provider: "byteplus",
|
||||
mode: "api_key",
|
||||
});
|
||||
const configWithModel = applyPrimaryModel(configWithAuth, BYTEPLUS_DEFAULT_MODEL);
|
||||
return {
|
||||
config: configWithModel,
|
||||
agentModelOverride: BYTEPLUS_DEFAULT_MODEL,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let key: string | undefined;
|
||||
if (params.opts?.byteplusApiKey) {
|
||||
key = params.opts.byteplusApiKey;
|
||||
} else {
|
||||
key = await params.prompter.text({
|
||||
message: "Enter BytePlus API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
await setByteplusApiKey(trimmed, params.agentDir, {
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.byteplusApiKey,
|
||||
tokenProvider: "byteplus",
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: params.config,
|
||||
expectedProviders: ["byteplus"],
|
||||
provider: "byteplus",
|
||||
envLabel: "BYTEPLUS_API_KEY",
|
||||
promptMessage: "Enter BytePlus API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setByteplusApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
|
||||
});
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "byteplus:default",
|
||||
|
||||
@@ -34,6 +34,7 @@ export async function applyAuthChoiceHuggingface(
|
||||
token: params.opts?.token,
|
||||
tokenProvider: params.opts?.tokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["huggingface"],
|
||||
provider: "huggingface",
|
||||
envLabel: "Hugging Face token",
|
||||
|
||||
@@ -41,6 +41,7 @@ export async function applyAuthChoiceMiniMax(
|
||||
token: params.opts?.token,
|
||||
tokenProvider: params.opts?.tokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["minimax", "minimax-cn"],
|
||||
provider: "minimax",
|
||||
envLabel: "MINIMAX_API_KEY",
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
createAuthChoiceAgentModelNoter,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
@@ -55,40 +50,20 @@ export async function applyAuthChoiceOpenAI(
|
||||
return { config: nextConfig, agentModelOverride };
|
||||
};
|
||||
|
||||
const envKey = resolveEnvApiKey("openai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing OPENAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setOpenaiApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "openai:default",
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
return await applyOpenAiDefaultModelChoice();
|
||||
}
|
||||
}
|
||||
|
||||
let key: string | undefined;
|
||||
if (params.opts?.token && params.opts?.tokenProvider === "openai") {
|
||||
key = params.opts.token;
|
||||
} else {
|
||||
key = await params.prompter.text({
|
||||
message: "Enter OpenAI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
await setOpenaiApiKey(trimmed, params.agentDir, {
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.token,
|
||||
tokenProvider: params.opts?.tokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["openai"],
|
||||
provider: "openai",
|
||||
envLabel: "OPENAI_API_KEY",
|
||||
promptMessage: "Enter OpenAI API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setOpenaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
|
||||
});
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "openai:default",
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../agents/auth-profiles.js";
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
createAuthChoiceAgentModelNoter,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
@@ -55,30 +50,20 @@ export async function applyAuthChoiceOpenRouter(
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const envKey = resolveEnvApiKey("openrouter");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing OPENROUTER_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setOpenrouterApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter OpenRouter API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
await setOpenrouterApiKey(normalizeApiKeyInput(String(key ?? "")), params.agentDir, {
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.token,
|
||||
tokenProvider: params.opts?.tokenProvider,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["openrouter"],
|
||||
provider: "openrouter",
|
||||
envLabel: "OPENROUTER_API_KEY",
|
||||
promptMessage: "Enter OpenRouter API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setOpenrouterApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
|
||||
});
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyPrimaryModel } from "./model-picker.js";
|
||||
@@ -23,44 +18,20 @@ export async function applyAuthChoiceVolcengine(
|
||||
}
|
||||
|
||||
const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode);
|
||||
const envKey = resolveEnvApiKey("volcengine");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing VOLCANO_ENGINE_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
await setVolcengineApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "volcengine:default",
|
||||
provider: "volcengine",
|
||||
mode: "api_key",
|
||||
});
|
||||
const configWithModel = applyPrimaryModel(configWithAuth, VOLCENGINE_DEFAULT_MODEL);
|
||||
return {
|
||||
config: configWithModel,
|
||||
agentModelOverride: VOLCENGINE_DEFAULT_MODEL,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let key: string | undefined;
|
||||
if (params.opts?.volcengineApiKey) {
|
||||
key = params.opts.volcengineApiKey;
|
||||
} else {
|
||||
key = await params.prompter.text({
|
||||
message: "Enter Volcano Engine API Key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
}
|
||||
|
||||
const trimmed = normalizeApiKeyInput(String(key));
|
||||
await setVolcengineApiKey(trimmed, params.agentDir, {
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.volcengineApiKey,
|
||||
tokenProvider: "volcengine",
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: params.config,
|
||||
expectedProviders: ["volcengine"],
|
||||
provider: "volcengine",
|
||||
envLabel: "VOLCANO_ENGINE_API_KEY",
|
||||
promptMessage: "Enter Volcano Engine API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setVolcengineApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
|
||||
});
|
||||
const configWithAuth = applyAuthProfileConfig(params.config, {
|
||||
profileId: "volcengine:default",
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { resolveEnvApiKey } from "../agents/model-auth.js";
|
||||
import {
|
||||
formatApiKeyPreview,
|
||||
normalizeApiKeyInput,
|
||||
validateApiKeyInput,
|
||||
} from "./auth-choice.api-key.js";
|
||||
import { normalizeApiKeyInput, validateApiKeyInput } from "./auth-choice.api-key.js";
|
||||
import {
|
||||
createAuthChoiceAgentModelNoter,
|
||||
ensureApiKeyFromOptionEnvOrPrompt,
|
||||
normalizeSecretInputModeInput,
|
||||
resolveSecretInputModeForEnvSelection,
|
||||
} from "./auth-choice.apply-helpers.js";
|
||||
import type { ApplyAuthChoiceParams, ApplyAuthChoiceResult } from "./auth-choice.apply.js";
|
||||
import { applyDefaultModelChoice } from "./auth-choice.default-model.js";
|
||||
@@ -30,43 +25,21 @@ export async function applyAuthChoiceXAI(
|
||||
let agentModelOverride: string | undefined;
|
||||
const noteAgentModel = createAuthChoiceAgentModelNoter(params);
|
||||
const requestedSecretInputMode = normalizeSecretInputModeInput(params.opts?.secretInputMode);
|
||||
|
||||
let hasCredential = false;
|
||||
const optsKey = params.opts?.xaiApiKey?.trim();
|
||||
if (optsKey) {
|
||||
setXaiApiKey(normalizeApiKeyInput(optsKey), params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
hasCredential = true;
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const envKey = resolveEnvApiKey("xai");
|
||||
if (envKey) {
|
||||
const useExisting = await params.prompter.confirm({
|
||||
message: `Use existing XAI_API_KEY (${envKey.source}, ${formatApiKeyPreview(envKey.apiKey)})?`,
|
||||
initialValue: true,
|
||||
});
|
||||
if (useExisting) {
|
||||
const mode = await resolveSecretInputModeForEnvSelection({
|
||||
prompter: params.prompter,
|
||||
explicitMode: requestedSecretInputMode,
|
||||
});
|
||||
setXaiApiKey(envKey.apiKey, params.agentDir, { secretInputMode: mode });
|
||||
hasCredential = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasCredential) {
|
||||
const key = await params.prompter.text({
|
||||
message: "Enter xAI API key",
|
||||
validate: validateApiKeyInput,
|
||||
});
|
||||
setXaiApiKey(normalizeApiKeyInput(String(key)), params.agentDir, {
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
}
|
||||
await ensureApiKeyFromOptionEnvOrPrompt({
|
||||
token: params.opts?.xaiApiKey,
|
||||
tokenProvider: "xai",
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
config: nextConfig,
|
||||
expectedProviders: ["xai"],
|
||||
provider: "xai",
|
||||
envLabel: "XAI_API_KEY",
|
||||
promptMessage: "Enter xAI API key",
|
||||
normalize: normalizeApiKeyInput,
|
||||
validate: validateApiKeyInput,
|
||||
prompter: params.prompter,
|
||||
setCredential: async (apiKey, mode) =>
|
||||
setXaiApiKey(apiKey, params.agentDir, { secretInputMode: mode }),
|
||||
});
|
||||
|
||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||
profileId: "xai:default",
|
||||
|
||||
@@ -630,6 +630,8 @@ describe("applyAuthChoice", () => {
|
||||
profileId: string;
|
||||
provider: string;
|
||||
opts?: { secretInputMode?: "ref" };
|
||||
expectEnvPrompt: boolean;
|
||||
expectedTextCalls: number;
|
||||
expectedKey?: string;
|
||||
expectedKeyRef?: { source: "env"; id: string };
|
||||
expectedModel?: string;
|
||||
@@ -641,6 +643,8 @@ describe("applyAuthChoice", () => {
|
||||
envValue: "sk-synthetic-env",
|
||||
profileId: "synthetic:default",
|
||||
provider: "synthetic",
|
||||
expectEnvPrompt: true,
|
||||
expectedTextCalls: 0,
|
||||
expectedKey: "sk-synthetic-env",
|
||||
expectedModelPrefix: "synthetic/",
|
||||
},
|
||||
@@ -650,6 +654,8 @@ describe("applyAuthChoice", () => {
|
||||
envValue: "sk-openrouter-test",
|
||||
profileId: "openrouter:default",
|
||||
provider: "openrouter",
|
||||
expectEnvPrompt: true,
|
||||
expectedTextCalls: 0,
|
||||
expectedKey: "sk-openrouter-test",
|
||||
expectedModel: "openrouter/auto",
|
||||
},
|
||||
@@ -659,6 +665,8 @@ describe("applyAuthChoice", () => {
|
||||
envValue: "gateway-test-key",
|
||||
profileId: "vercel-ai-gateway:default",
|
||||
provider: "vercel-ai-gateway",
|
||||
expectEnvPrompt: true,
|
||||
expectedTextCalls: 0,
|
||||
expectedKey: "gateway-test-key",
|
||||
expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6",
|
||||
},
|
||||
@@ -669,6 +677,8 @@ describe("applyAuthChoice", () => {
|
||||
profileId: "vercel-ai-gateway:default",
|
||||
provider: "vercel-ai-gateway",
|
||||
opts: { secretInputMode: "ref" },
|
||||
expectEnvPrompt: false,
|
||||
expectedTextCalls: 1,
|
||||
expectedKeyRef: { source: "env", id: "AI_GATEWAY_API_KEY" },
|
||||
expectedModel: "vercel-ai-gateway/anthropic/claude-opus-4.6",
|
||||
},
|
||||
@@ -693,12 +703,16 @@ describe("applyAuthChoice", () => {
|
||||
opts: scenario.opts,
|
||||
});
|
||||
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(scenario.envKey),
|
||||
}),
|
||||
);
|
||||
expect(text).not.toHaveBeenCalled();
|
||||
if (scenario.expectEnvPrompt) {
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: expect.stringContaining(scenario.envKey),
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls);
|
||||
expect(result.config.auth?.profiles?.[scenario.profileId]).toMatchObject({
|
||||
provider: scenario.provider,
|
||||
mode: "api_key",
|
||||
@@ -726,6 +740,57 @@ describe("applyAuthChoice", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("retries ref setup when sops preflight fails and can switch to env ref", async () => {
|
||||
await setupTempState();
|
||||
process.env.OPENAI_API_KEY = "sk-openai-env";
|
||||
|
||||
const selectValues: Array<"file" | "env"> = ["file", "env"];
|
||||
const select = vi.fn(async (params: Parameters<WizardPrompter["select"]>[0]) => {
|
||||
if (params.options.some((option) => option.value === "file")) {
|
||||
return (selectValues.shift() ?? "env") as never;
|
||||
}
|
||||
return (params.options[0]?.value ?? "env") as never;
|
||||
});
|
||||
const text = vi
|
||||
.fn<WizardPrompter["text"]>()
|
||||
.mockResolvedValueOnce("/providers/openai/apiKey")
|
||||
.mockResolvedValueOnce("OPENAI_API_KEY");
|
||||
const note = vi.fn(async () => undefined);
|
||||
|
||||
const prompter = createPrompter({
|
||||
select,
|
||||
text,
|
||||
note,
|
||||
confirm: vi.fn(async () => true),
|
||||
});
|
||||
const runtime = createExitThrowingRuntime();
|
||||
|
||||
const result = await applyAuthChoice({
|
||||
authChoice: "openai-api-key",
|
||||
config: {},
|
||||
prompter,
|
||||
runtime,
|
||||
setDefaultModel: false,
|
||||
opts: { secretInputMode: "ref" },
|
||||
});
|
||||
|
||||
expect(result.config.auth?.profiles?.["openai:default"]).toMatchObject({
|
||||
provider: "openai",
|
||||
mode: "api_key",
|
||||
});
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not validate this encrypted file reference."),
|
||||
"Reference check failed",
|
||||
);
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Validated environment variable OPENAI_API_KEY."),
|
||||
"Reference validated",
|
||||
);
|
||||
expect(await readAuthProfile("openai:default")).toMatchObject({
|
||||
keyRef: { source: "env", id: "OPENAI_API_KEY" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing default model for explicit provider keys when setDefaultModel=false", async () => {
|
||||
const scenarios: Array<{
|
||||
authChoice: "xai-api-key" | "opencode-zen";
|
||||
@@ -947,6 +1012,7 @@ describe("applyAuthChoice", () => {
|
||||
cloudflareAiGatewayApiKey?: string;
|
||||
};
|
||||
expectEnvPrompt: boolean;
|
||||
expectedTextCalls: number;
|
||||
expectedKey?: string;
|
||||
expectedKeyRef?: { source: string; id: string };
|
||||
expectedMetadata: { accountId: string; gatewayId: string };
|
||||
@@ -956,6 +1022,7 @@ describe("applyAuthChoice", () => {
|
||||
textValues: ["cf-account-id", "cf-gateway-id"],
|
||||
confirmValue: true,
|
||||
expectEnvPrompt: true,
|
||||
expectedTextCalls: 2,
|
||||
expectedKey: "cf-gateway-test-key",
|
||||
expectedMetadata: {
|
||||
accountId: "cf-account-id",
|
||||
@@ -969,7 +1036,8 @@ describe("applyAuthChoice", () => {
|
||||
opts: {
|
||||
secretInputMode: "ref",
|
||||
},
|
||||
expectEnvPrompt: true,
|
||||
expectEnvPrompt: false,
|
||||
expectedTextCalls: 3,
|
||||
expectedKeyRef: {
|
||||
source: "env",
|
||||
id: "CLOUDFLARE_AI_GATEWAY_API_KEY",
|
||||
@@ -988,6 +1056,7 @@ describe("applyAuthChoice", () => {
|
||||
cloudflareAiGatewayApiKey: "cf-direct-key",
|
||||
},
|
||||
expectEnvPrompt: false,
|
||||
expectedTextCalls: 0,
|
||||
expectedKey: "cf-direct-key",
|
||||
expectedMetadata: {
|
||||
accountId: "acc-direct",
|
||||
@@ -1027,7 +1096,7 @@ describe("applyAuthChoice", () => {
|
||||
} else {
|
||||
expect(confirm).not.toHaveBeenCalled();
|
||||
}
|
||||
expect(text).toHaveBeenCalledTimes(scenario.textValues.length);
|
||||
expect(text).toHaveBeenCalledTimes(scenario.expectedTextCalls);
|
||||
expect(result.config.auth?.profiles?.["cloudflare-ai-gateway:default"]).toMatchObject({
|
||||
provider: "cloudflare-ai-gateway",
|
||||
mode: "api_key",
|
||||
|
||||
@@ -78,48 +78,49 @@ function expectOpenAiCompatResult(params: {
|
||||
describe("promptCustomApiConfig", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("handles openai flow and saves alias", async () => {
|
||||
const prompter = createTestPrompter({
|
||||
text: ["http://localhost:11434/v1", "", "llama3", "custom", "local"],
|
||||
select: ["openai"],
|
||||
select: ["plaintext", "openai"],
|
||||
});
|
||||
stubFetchSequence([{ ok: true }]);
|
||||
const result = await runPromptCustomApi(prompter);
|
||||
|
||||
expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result });
|
||||
expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 2, result });
|
||||
expect(result.config.agents?.defaults?.models?.["custom/llama3"]?.alias).toBe("local");
|
||||
});
|
||||
|
||||
it("retries when verification fails", async () => {
|
||||
const prompter = createTestPrompter({
|
||||
text: ["http://localhost:11434/v1", "", "bad-model", "good-model", "custom", ""],
|
||||
select: ["openai", "model"],
|
||||
select: ["plaintext", "openai", "model"],
|
||||
});
|
||||
stubFetchSequence([{ ok: false, status: 400 }, { ok: true }]);
|
||||
await runPromptCustomApi(prompter);
|
||||
|
||||
expect(prompter.text).toHaveBeenCalledTimes(6);
|
||||
expect(prompter.select).toHaveBeenCalledTimes(2);
|
||||
expect(prompter.select).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("detects openai compatibility when unknown", async () => {
|
||||
const prompter = createTestPrompter({
|
||||
text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"],
|
||||
select: ["unknown"],
|
||||
select: ["plaintext", "unknown"],
|
||||
});
|
||||
stubFetchSequence([{ ok: true }]);
|
||||
const result = await runPromptCustomApi(prompter);
|
||||
|
||||
expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 1, result });
|
||||
expectOpenAiCompatResult({ prompter, textCalls: 5, selectCalls: 2, result });
|
||||
});
|
||||
|
||||
it("uses expanded max_tokens for openai verification probes", async () => {
|
||||
const prompter = createTestPrompter({
|
||||
text: ["https://example.com/v1", "test-key", "detected-model", "custom", "alias"],
|
||||
select: ["openai"],
|
||||
select: ["plaintext", "openai"],
|
||||
});
|
||||
const fetchMock = stubFetchSequence([{ ok: true }]);
|
||||
|
||||
@@ -133,7 +134,7 @@ describe("promptCustomApiConfig", () => {
|
||||
it("uses expanded max_tokens for anthropic verification probes", async () => {
|
||||
const prompter = createTestPrompter({
|
||||
text: ["https://example.com", "test-key", "detected-model", "custom", "alias"],
|
||||
select: ["unknown"],
|
||||
select: ["plaintext", "unknown"],
|
||||
});
|
||||
const fetchMock = stubFetchSequence([{ ok: false, status: 404 }, { ok: true }]);
|
||||
|
||||
@@ -156,7 +157,7 @@ describe("promptCustomApiConfig", () => {
|
||||
"custom",
|
||||
"",
|
||||
],
|
||||
select: ["unknown", "baseUrl"],
|
||||
select: ["plaintext", "unknown", "baseUrl", "plaintext"],
|
||||
});
|
||||
stubFetchSequence([{ ok: false, status: 404 }, { ok: false, status: 404 }, { ok: true }]);
|
||||
await runPromptCustomApi(prompter);
|
||||
@@ -170,7 +171,7 @@ describe("promptCustomApiConfig", () => {
|
||||
it("renames provider id when baseUrl differs", async () => {
|
||||
const prompter = createTestPrompter({
|
||||
text: ["http://localhost:11434/v1", "", "llama3", "custom", ""],
|
||||
select: ["openai"],
|
||||
select: ["plaintext", "openai"],
|
||||
});
|
||||
stubFetchSequence([{ ok: true }]);
|
||||
const result = await runPromptCustomApi(prompter, {
|
||||
@@ -204,7 +205,7 @@ describe("promptCustomApiConfig", () => {
|
||||
vi.useFakeTimers();
|
||||
const prompter = createTestPrompter({
|
||||
text: ["http://localhost:11434/v1", "", "slow-model", "fast-model", "custom", ""],
|
||||
select: ["openai", "model"],
|
||||
select: ["plaintext", "openai", "model"],
|
||||
});
|
||||
|
||||
const fetchMock = vi
|
||||
@@ -224,6 +225,53 @@ describe("promptCustomApiConfig", () => {
|
||||
|
||||
expect(prompter.text).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
|
||||
it("stores env SecretRef for custom provider when selected", async () => {
|
||||
vi.stubEnv("CUSTOM_PROVIDER_API_KEY", "test-env-key");
|
||||
const prompter = createTestPrompter({
|
||||
text: ["https://example.com/v1", "CUSTOM_PROVIDER_API_KEY", "detected-model", "custom", ""],
|
||||
select: ["ref", "env", "openai"],
|
||||
});
|
||||
const fetchMock = stubFetchSequence([{ ok: true }]);
|
||||
|
||||
const result = await runPromptCustomApi(prompter);
|
||||
|
||||
expect(result.config.models?.providers?.custom?.apiKey).toEqual({
|
||||
source: "env",
|
||||
id: "CUSTOM_PROVIDER_API_KEY",
|
||||
});
|
||||
const firstCall = fetchMock.mock.calls[0]?.[1] as
|
||||
| { headers?: Record<string, string> }
|
||||
| undefined;
|
||||
expect(firstCall?.headers?.Authorization).toBe("Bearer test-env-key");
|
||||
});
|
||||
|
||||
it("re-prompts source after encrypted file ref preflight fails and succeeds with env ref", async () => {
|
||||
vi.stubEnv("CUSTOM_PROVIDER_API_KEY", "test-env-key");
|
||||
const prompter = createTestPrompter({
|
||||
text: [
|
||||
"https://example.com/v1",
|
||||
"/providers/custom/apiKey",
|
||||
"CUSTOM_PROVIDER_API_KEY",
|
||||
"detected-model",
|
||||
"custom",
|
||||
"",
|
||||
],
|
||||
select: ["ref", "file", "env", "openai"],
|
||||
});
|
||||
stubFetchSequence([{ ok: true }]);
|
||||
|
||||
const result = await runPromptCustomApi(prompter);
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Could not validate this encrypted file reference."),
|
||||
"Reference check failed",
|
||||
);
|
||||
expect(result.config.models?.providers?.custom?.apiKey).toEqual({
|
||||
source: "env",
|
||||
id: "CUSTOM_PROVIDER_API_KEY",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomApiConfig", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -48,7 +48,7 @@ type ProviderAuthConfigSnapshot = {
|
||||
{
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
apiKey?: string;
|
||||
apiKey?: string | { source?: string; id?: string };
|
||||
models?: Array<{ id?: string }>;
|
||||
}
|
||||
>;
|
||||
@@ -145,6 +145,14 @@ async function runCustomLocalNonInteractive(
|
||||
}
|
||||
|
||||
async function readCustomLocalProviderApiKey(configPath: string): Promise<string | undefined> {
|
||||
const cfg = await readJsonFile<ProviderAuthConfigSnapshot>(configPath);
|
||||
const apiKey = cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey;
|
||||
return typeof apiKey === "string" ? apiKey : undefined;
|
||||
}
|
||||
|
||||
async function readCustomLocalProviderApiKeyInput(
|
||||
configPath: string,
|
||||
): Promise<string | { source?: string; id?: string } | undefined> {
|
||||
const cfg = await readJsonFile<ProviderAuthConfigSnapshot>(configPath);
|
||||
return cfg.models?.providers?.[CUSTOM_LOCAL_PROVIDER_ID]?.apiKey;
|
||||
}
|
||||
@@ -349,6 +357,91 @@ describe("onboard (non-interactive): provider auth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "anthropic",
|
||||
prefix: "openclaw-onboard-ref-flag-anthropic-",
|
||||
authChoice: "apiKey",
|
||||
optionKey: "anthropicApiKey",
|
||||
flagName: "--anthropic-api-key",
|
||||
envVar: "ANTHROPIC_API_KEY",
|
||||
},
|
||||
{
|
||||
name: "openai",
|
||||
prefix: "openclaw-onboard-ref-flag-openai-",
|
||||
authChoice: "openai-api-key",
|
||||
optionKey: "openaiApiKey",
|
||||
flagName: "--openai-api-key",
|
||||
envVar: "OPENAI_API_KEY",
|
||||
},
|
||||
{
|
||||
name: "openrouter",
|
||||
prefix: "openclaw-onboard-ref-flag-openrouter-",
|
||||
authChoice: "openrouter-api-key",
|
||||
optionKey: "openrouterApiKey",
|
||||
flagName: "--openrouter-api-key",
|
||||
envVar: "OPENROUTER_API_KEY",
|
||||
},
|
||||
{
|
||||
name: "xai",
|
||||
prefix: "openclaw-onboard-ref-flag-xai-",
|
||||
authChoice: "xai-api-key",
|
||||
optionKey: "xaiApiKey",
|
||||
flagName: "--xai-api-key",
|
||||
envVar: "XAI_API_KEY",
|
||||
},
|
||||
{
|
||||
name: "volcengine",
|
||||
prefix: "openclaw-onboard-ref-flag-volcengine-",
|
||||
authChoice: "volcengine-api-key",
|
||||
optionKey: "volcengineApiKey",
|
||||
flagName: "--volcengine-api-key",
|
||||
envVar: "VOLCANO_ENGINE_API_KEY",
|
||||
},
|
||||
{
|
||||
name: "byteplus",
|
||||
prefix: "openclaw-onboard-ref-flag-byteplus-",
|
||||
authChoice: "byteplus-api-key",
|
||||
optionKey: "byteplusApiKey",
|
||||
flagName: "--byteplus-api-key",
|
||||
envVar: "BYTEPLUS_API_KEY",
|
||||
},
|
||||
])(
|
||||
"fails fast for $name when --secret-input-mode ref uses explicit key without env and does not leak the key",
|
||||
async ({ prefix, authChoice, optionKey, flagName, envVar }) => {
|
||||
await withOnboardEnv(prefix, async ({ runtime }) => {
|
||||
const providedSecret = `${envVar.toLowerCase()}-should-not-leak`;
|
||||
const options: Record<string, unknown> = {
|
||||
authChoice,
|
||||
secretInputMode: "ref",
|
||||
[optionKey]: providedSecret,
|
||||
skipSkills: true,
|
||||
};
|
||||
const envOverrides: Record<string, string | undefined> = {
|
||||
[envVar]: undefined,
|
||||
};
|
||||
|
||||
await withEnvAsync(envOverrides, async () => {
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
await runNonInteractiveOnboardingWithDefaults(runtime, options);
|
||||
} catch (error) {
|
||||
thrown = error as Error;
|
||||
}
|
||||
expect(thrown).toBeDefined();
|
||||
const message = String(thrown?.message ?? "");
|
||||
expect(message).toContain(
|
||||
`${flagName} cannot be used with --secret-input-mode ref unless ${envVar} is set in env.`,
|
||||
);
|
||||
expect(message).toContain(
|
||||
`Set ${envVar} in env and omit ${flagName}, or use --secret-input-mode plaintext.`,
|
||||
);
|
||||
expect(message).not.toContain(providedSecret);
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects vLLM auth choice in non-interactive mode", async () => {
|
||||
await withOnboardEnv("openclaw-onboard-vllm-non-interactive-", async ({ runtime }) => {
|
||||
await expect(
|
||||
@@ -508,6 +601,48 @@ describe("onboard (non-interactive): provider auth", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("stores CUSTOM_API_KEY env ref for non-interactive custom provider auth in ref mode", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-env-ref-",
|
||||
async ({ configPath, runtime }) => {
|
||||
process.env.CUSTOM_API_KEY = "custom-env-key";
|
||||
await runCustomLocalNonInteractive(runtime, {
|
||||
secretInputMode: "ref",
|
||||
});
|
||||
expect(await readCustomLocalProviderApiKeyInput(configPath)).toEqual({
|
||||
source: "env",
|
||||
id: "CUSTOM_API_KEY",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("fails fast for custom provider ref mode when --custom-api-key is set but CUSTOM_API_KEY env is missing", async () => {
|
||||
await withOnboardEnv("openclaw-onboard-custom-provider-ref-flag-", async ({ runtime }) => {
|
||||
const providedSecret = "custom-inline-key-should-not-leak";
|
||||
await withEnvAsync({ CUSTOM_API_KEY: undefined }, async () => {
|
||||
let thrown: Error | undefined;
|
||||
try {
|
||||
await runCustomLocalNonInteractive(runtime, {
|
||||
secretInputMode: "ref",
|
||||
customApiKey: providedSecret,
|
||||
});
|
||||
} catch (error) {
|
||||
thrown = error as Error;
|
||||
}
|
||||
expect(thrown).toBeDefined();
|
||||
const message = String(thrown?.message ?? "");
|
||||
expect(message).toContain(
|
||||
"--custom-api-key cannot be used with --secret-input-mode ref unless CUSTOM_API_KEY is set in env.",
|
||||
);
|
||||
expect(message).toContain(
|
||||
"Set CUSTOM_API_KEY in env and omit --custom-api-key, or use --secret-input-mode plaintext.",
|
||||
);
|
||||
expect(message).not.toContain(providedSecret);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("uses matching profile fallback for non-interactive custom provider auth", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-profile-fallback-",
|
||||
|
||||
@@ -7,6 +7,7 @@ import { resolveEnvApiKey } from "../../agents/model-auth.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import { normalizeOptionalSecretInput } from "../../utils/normalize-secret-input.js";
|
||||
import type { SecretInputMode } from "../onboard-types.js";
|
||||
|
||||
export type NonInteractiveApiKeySource = "flag" | "env" | "profile";
|
||||
|
||||
@@ -50,23 +51,38 @@ export async function resolveNonInteractiveApiKey(params: {
|
||||
agentDir?: string;
|
||||
allowProfile?: boolean;
|
||||
required?: boolean;
|
||||
secretInputMode?: SecretInputMode;
|
||||
}): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> {
|
||||
const flagKey = normalizeOptionalSecretInput(params.flagValue);
|
||||
const envResolved = resolveEnvApiKey(params.provider);
|
||||
const explicitEnvVar = params.envVarName?.trim();
|
||||
const explicitEnvKey = explicitEnvVar
|
||||
? normalizeOptionalSecretInput(process.env[explicitEnvVar])
|
||||
: undefined;
|
||||
const resolvedEnvKey = envResolved?.apiKey ?? explicitEnvKey;
|
||||
|
||||
if (params.secretInputMode === "ref") {
|
||||
if (!resolvedEnvKey && flagKey) {
|
||||
params.runtime.error(
|
||||
[
|
||||
`${params.flagName} cannot be used with --secret-input-mode ref unless ${params.envVar} is set in env.`,
|
||||
`Set ${params.envVar} in env and omit ${params.flagName}, or use --secret-input-mode plaintext.`,
|
||||
].join("\n"),
|
||||
);
|
||||
params.runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
if (resolvedEnvKey) {
|
||||
return { key: resolvedEnvKey, source: "env" };
|
||||
}
|
||||
}
|
||||
|
||||
if (flagKey) {
|
||||
return { key: flagKey, source: "flag" };
|
||||
}
|
||||
|
||||
const envResolved = resolveEnvApiKey(params.provider);
|
||||
if (envResolved?.apiKey) {
|
||||
return { key: envResolved.apiKey, source: "env" };
|
||||
}
|
||||
|
||||
const explicitEnvVar = params.envVarName?.trim();
|
||||
if (explicitEnvVar) {
|
||||
const explicitEnvKey = normalizeOptionalSecretInput(process.env[explicitEnvVar]);
|
||||
if (explicitEnvKey) {
|
||||
return { key: explicitEnvKey, source: "env" };
|
||||
}
|
||||
if (resolvedEnvKey) {
|
||||
return { key: resolvedEnvKey, source: "env" };
|
||||
}
|
||||
|
||||
if (params.allowProfile ?? true) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { upsertAuthProfile } from "../../../agents/auth-profiles.js";
|
||||
import { normalizeProviderId } from "../../../agents/model-selection.js";
|
||||
import { parseDurationMs } from "../../../cli/parse-duration.js";
|
||||
import type { OpenClawConfig } from "../../../config/config.js";
|
||||
import type { SecretInput } from "../../../config/types.secrets.js";
|
||||
import type { RuntimeEnv } from "../../../runtime.js";
|
||||
import { normalizeSecretInput } from "../../../utils/normalize-secret-input.js";
|
||||
import { normalizeSecretInputModeInput } from "../../auth-choice.apply-helpers.js";
|
||||
@@ -84,6 +85,13 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
const apiKeyStorageOptions = requestedSecretInputMode
|
||||
? { secretInputMode: requestedSecretInputMode }
|
||||
: undefined;
|
||||
const resolveApiKey = (
|
||||
input: Parameters<typeof resolveNonInteractiveApiKey>[0],
|
||||
): ReturnType<typeof resolveNonInteractiveApiKey> =>
|
||||
resolveNonInteractiveApiKey({
|
||||
...input,
|
||||
secretInputMode: requestedSecretInputMode,
|
||||
});
|
||||
|
||||
if (authChoice === "claude-cli" || authChoice === "codex-cli") {
|
||||
runtime.error(
|
||||
@@ -119,7 +127,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "apiKey") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "anthropic",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.anthropicApiKey,
|
||||
@@ -196,7 +204,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "gemini-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "google",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.geminiApiKey,
|
||||
@@ -225,7 +233,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
authChoice === "zai-global" ||
|
||||
authChoice === "zai-cn"
|
||||
) {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "zai",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.zaiApiKey,
|
||||
@@ -274,7 +282,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "xiaomi-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "xiaomi",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.xiaomiApiKey,
|
||||
@@ -297,7 +305,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "xai-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "xai",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.xaiApiKey,
|
||||
@@ -320,7 +328,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "mistral-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "mistral",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.mistralApiKey,
|
||||
@@ -343,7 +351,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "volcengine-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "volcengine",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.volcengineApiKey,
|
||||
@@ -366,7 +374,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "byteplus-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "byteplus",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.byteplusApiKey,
|
||||
@@ -389,7 +397,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "qianfan-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "qianfan",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.qianfanApiKey,
|
||||
@@ -412,7 +420,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "openai-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "openai",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.openaiApiKey,
|
||||
@@ -435,7 +443,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "openrouter-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "openrouter",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.openrouterApiKey,
|
||||
@@ -458,7 +466,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "kilocode-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "kilocode",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.kilocodeApiKey,
|
||||
@@ -481,7 +489,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "litellm-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "litellm",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.litellmApiKey,
|
||||
@@ -504,7 +512,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "ai-gateway-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "vercel-ai-gateway",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.aiGatewayApiKey,
|
||||
@@ -539,7 +547,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "cloudflare-ai-gateway",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.cloudflareAiGatewayApiKey,
|
||||
@@ -573,7 +581,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
const applyMoonshotApiKeyChoice = async (
|
||||
applyConfig: (cfg: OpenClawConfig) => OpenClawConfig,
|
||||
): Promise<OpenClawConfig | null> => {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "moonshot",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.moonshotApiKey,
|
||||
@@ -604,7 +612,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "kimi-code-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "kimi-coding",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.kimiCodeApiKey,
|
||||
@@ -627,7 +635,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "synthetic-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "synthetic",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.syntheticApiKey,
|
||||
@@ -650,7 +658,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "venice-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "venice",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.veniceApiKey,
|
||||
@@ -681,7 +689,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
const isCn = authChoice === "minimax-api-key-cn";
|
||||
const providerId = isCn ? "minimax-cn" : "minimax";
|
||||
const profileId = `${providerId}:default`;
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: providerId,
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.minimaxApiKey,
|
||||
@@ -712,7 +720,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "opencode-zen") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "opencode",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.opencodeZenApiKey,
|
||||
@@ -735,7 +743,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "together-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "together",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.togetherApiKey,
|
||||
@@ -758,7 +766,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "huggingface-api-key") {
|
||||
const resolved = await resolveNonInteractiveApiKey({
|
||||
const resolved = await resolveApiKey({
|
||||
provider: "huggingface",
|
||||
cfg: baseConfig,
|
||||
flagValue: opts.huggingfaceApiKey,
|
||||
@@ -794,7 +802,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
baseUrl: customAuth.baseUrl,
|
||||
providerId: customAuth.providerId,
|
||||
});
|
||||
const resolvedCustomApiKey = await resolveNonInteractiveApiKey({
|
||||
const resolvedCustomApiKey = await resolveApiKey({
|
||||
provider: resolvedProviderId.providerId,
|
||||
cfg: baseConfig,
|
||||
flagValue: customAuth.apiKey,
|
||||
@@ -804,12 +812,16 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
runtime,
|
||||
required: false,
|
||||
});
|
||||
const customApiKeyInput: SecretInput | undefined =
|
||||
requestedSecretInputMode === "ref" && resolvedCustomApiKey?.source === "env"
|
||||
? { source: "env", id: "CUSTOM_API_KEY" }
|
||||
: resolvedCustomApiKey?.key;
|
||||
const result = applyCustomApiConfig({
|
||||
config: nextConfig,
|
||||
baseUrl: customAuth.baseUrl,
|
||||
modelId: customAuth.modelId,
|
||||
compatibility: customAuth.compatibility,
|
||||
apiKey: resolvedCustomApiKey?.key,
|
||||
apiKey: customApiKeyInput,
|
||||
providerId: customAuth.providerId,
|
||||
});
|
||||
if (result.providerIdRenamedFrom && result.providerId) {
|
||||
|
||||
@@ -367,6 +367,7 @@ export async function runOnboardingWizard(
|
||||
prompter,
|
||||
runtime,
|
||||
config: nextConfig,
|
||||
secretInputMode: opts.secretInputMode,
|
||||
});
|
||||
nextConfig = customResult.config;
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user