mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 10:17:26 +00:00
fix: harden custom provider non-interactive auth (openclaw#14223) thanks @ENCHIGO
This commit is contained in:
@@ -303,7 +303,7 @@ Options:
|
||||
- `--non-interactive`
|
||||
- `--mode <local|remote>`
|
||||
- `--flow <quickstart|advanced|manual>` (manual is an alias for advanced)
|
||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|skip>`
|
||||
- `--auth-choice <setup-token|token|chutes|openai-codex|openai-api-key|openrouter-api-key|ai-gateway-api-key|moonshot-api-key|moonshot-api-key-cn|kimi-code-api-key|synthetic-api-key|venice-api-key|gemini-api-key|zai-api-key|apiKey|minimax-api|minimax-api-lightning|opencode-zen|custom-api-key|skip>`
|
||||
- `--token-provider <id>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token <token>` (non-interactive; used with `--auth-choice token`)
|
||||
- `--token-profile-id <id>` (non-interactive; default: `<provider>:manual`)
|
||||
@@ -318,6 +318,11 @@ Options:
|
||||
- `--zai-api-key <key>`
|
||||
- `--minimax-api-key <key>`
|
||||
- `--opencode-zen-api-key <key>`
|
||||
- `--custom-base-url <url>` (non-interactive; used with `--auth-choice custom-api-key`)
|
||||
- `--custom-model-id <id>` (non-interactive; used with `--auth-choice custom-api-key`)
|
||||
- `--custom-api-key <key>` (non-interactive; optional; used with `--auth-choice custom-api-key`; falls back to `CUSTOM_API_KEY` when omitted)
|
||||
- `--custom-provider-id <id>` (non-interactive; optional custom provider id)
|
||||
- `--custom-compatibility <openai|anthropic>` (non-interactive; optional; default `openai`)
|
||||
- `--gateway-port <port>`
|
||||
- `--gateway-bind <loopback|lan|tailnet|auto|custom>`
|
||||
- `--gateway-auth <token|password>`
|
||||
|
||||
@@ -26,6 +26,19 @@ openclaw onboard --flow manual
|
||||
openclaw onboard --mode remote --remote-url ws://gateway-host:18789
|
||||
```
|
||||
|
||||
Non-interactive custom provider:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice custom-api-key \
|
||||
--custom-base-url "https://llm.example.com/v1" \
|
||||
--custom-model-id "foo-large" \
|
||||
--custom-api-key "$CUSTOM_API_KEY" \
|
||||
--custom-compatibility openai
|
||||
```
|
||||
|
||||
`--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||
|
||||
Flow notes:
|
||||
|
||||
- `quickstart`: minimal prompts, auto-generates a gateway token.
|
||||
|
||||
@@ -106,6 +106,23 @@ Add `--json` for a machine-readable summary.
|
||||
--gateway-bind loopback
|
||||
```
|
||||
</Accordion>
|
||||
<Accordion title="Custom provider example">
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
--mode local \
|
||||
--auth-choice custom-api-key \
|
||||
--custom-base-url "https://llm.example.com/v1" \
|
||||
--custom-model-id "foo-large" \
|
||||
--custom-api-key "$CUSTOM_API_KEY" \
|
||||
--custom-provider-id "my-custom" \
|
||||
--custom-compatibility anthropic \
|
||||
--gateway-port 18789 \
|
||||
--gateway-bind loopback
|
||||
```
|
||||
|
||||
`--custom-api-key` is optional. If omitted, onboarding checks `CUSTOM_API_KEY`.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Add another agent
|
||||
|
||||
@@ -175,6 +175,18 @@ What you set:
|
||||
Moonshot (Kimi K2) and Kimi Coding configs are auto-written.
|
||||
More detail: [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot).
|
||||
</Accordion>
|
||||
<Accordion title="Custom provider">
|
||||
Works with OpenAI-compatible and Anthropic-compatible endpoints.
|
||||
|
||||
Non-interactive flags:
|
||||
- `--auth-choice custom-api-key`
|
||||
- `--custom-base-url`
|
||||
- `--custom-model-id`
|
||||
- `--custom-api-key` (optional; falls back to `CUSTOM_API_KEY`)
|
||||
- `--custom-provider-id` (optional)
|
||||
- `--custom-compatibility <openai|anthropic>` (optional; default `openai`)
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Skip">
|
||||
Leaves auth unconfigured.
|
||||
</Accordion>
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { promptCustomApiConfig } from "./onboard-custom.js";
|
||||
import {
|
||||
applyCustomApiConfig,
|
||||
parseNonInteractiveCustomApiFlags,
|
||||
promptCustomApiConfig,
|
||||
} from "./onboard-custom.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./model-picker.js", () => ({
|
||||
@@ -268,3 +272,75 @@ describe("promptCustomApiConfig", () => {
|
||||
expect(prompter.text).toHaveBeenCalledTimes(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCustomApiConfig", () => {
|
||||
it("rejects invalid compatibility values at runtime", () => {
|
||||
expect(() =>
|
||||
applyCustomApiConfig({
|
||||
config: {},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "invalid" as unknown as "openai",
|
||||
}),
|
||||
).toThrow('Custom provider compatibility must be "openai" or "anthropic".');
|
||||
});
|
||||
|
||||
it("rejects explicit provider ids that normalize to empty", () => {
|
||||
expect(() =>
|
||||
applyCustomApiConfig({
|
||||
config: {},
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
providerId: "!!!",
|
||||
}),
|
||||
).toThrow("Custom provider ID must include letters, numbers, or hyphens.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseNonInteractiveCustomApiFlags", () => {
|
||||
it("parses required flags and defaults compatibility to openai", () => {
|
||||
const result = parseNonInteractiveCustomApiFlags({
|
||||
baseUrl: " https://llm.example.com/v1 ",
|
||||
modelId: " foo-large ",
|
||||
apiKey: " custom-test-key ",
|
||||
providerId: " my-custom ",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "openai",
|
||||
apiKey: "custom-test-key",
|
||||
providerId: "my-custom",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing required flags", () => {
|
||||
expect(() =>
|
||||
parseNonInteractiveCustomApiFlags({
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
}),
|
||||
).toThrow('Auth choice "custom-api-key" requires a base URL and model ID.');
|
||||
});
|
||||
|
||||
it("rejects invalid compatibility values", () => {
|
||||
expect(() =>
|
||||
parseNonInteractiveCustomApiFlags({
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
compatibility: "xmlrpc",
|
||||
}),
|
||||
).toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").');
|
||||
});
|
||||
|
||||
it("rejects invalid explicit provider ids", () => {
|
||||
expect(() =>
|
||||
parseNonInteractiveCustomApiFlags({
|
||||
baseUrl: "https://llm.example.com/v1",
|
||||
modelId: "foo-large",
|
||||
providerId: "!!!",
|
||||
}),
|
||||
).toThrow("Custom provider ID must include letters, numbers, or hyphens.");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,51 @@ export type ApplyCustomApiConfigParams = {
|
||||
alias?: string;
|
||||
};
|
||||
|
||||
export type ParseNonInteractiveCustomApiFlagsParams = {
|
||||
baseUrl?: string;
|
||||
modelId?: string;
|
||||
compatibility?: string;
|
||||
apiKey?: string;
|
||||
providerId?: string;
|
||||
};
|
||||
|
||||
export type ParsedNonInteractiveCustomApiFlags = {
|
||||
baseUrl: string;
|
||||
modelId: string;
|
||||
compatibility: CustomApiCompatibility;
|
||||
apiKey?: string;
|
||||
providerId?: string;
|
||||
};
|
||||
|
||||
export type CustomApiErrorCode =
|
||||
| "missing_required"
|
||||
| "invalid_compatibility"
|
||||
| "invalid_base_url"
|
||||
| "invalid_model_id"
|
||||
| "invalid_provider_id"
|
||||
| "invalid_alias";
|
||||
|
||||
export class CustomApiError extends Error {
|
||||
readonly code: CustomApiErrorCode;
|
||||
|
||||
constructor(code: CustomApiErrorCode, message: string) {
|
||||
super(message);
|
||||
this.name = "CustomApiError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolveCustomProviderIdParams = {
|
||||
config: OpenClawConfig;
|
||||
baseUrl: string;
|
||||
providerId?: string;
|
||||
};
|
||||
|
||||
export type ResolvedCustomProviderId = {
|
||||
providerId: string;
|
||||
providerIdRenamedFrom?: string;
|
||||
};
|
||||
|
||||
const COMPATIBILITY_OPTIONS: Array<{
|
||||
value: CustomApiCompatibilityChoice;
|
||||
label: string;
|
||||
@@ -260,27 +305,108 @@ function resolveProviderApi(
|
||||
return compatibility === "anthropic" ? "anthropic-messages" : "openai-completions";
|
||||
}
|
||||
|
||||
export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): CustomApiResult {
|
||||
const baseUrl = params.baseUrl.trim();
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
throw new Error("Custom provider base URL must be a valid URL.");
|
||||
function parseCustomApiCompatibility(raw?: string): CustomApiCompatibility {
|
||||
const compatibilityRaw = raw?.trim().toLowerCase();
|
||||
if (!compatibilityRaw) {
|
||||
return "openai";
|
||||
}
|
||||
|
||||
const modelId = params.modelId.trim();
|
||||
if (!modelId) {
|
||||
throw new Error("Custom provider model ID is required.");
|
||||
if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") {
|
||||
throw new CustomApiError(
|
||||
"invalid_compatibility",
|
||||
'Invalid --custom-compatibility (use "openai" or "anthropic").',
|
||||
);
|
||||
}
|
||||
return compatibilityRaw;
|
||||
}
|
||||
|
||||
export function resolveCustomProviderId(
|
||||
params: ResolveCustomProviderIdParams,
|
||||
): ResolvedCustomProviderId {
|
||||
const providers = params.config.models?.providers ?? {};
|
||||
const requestedProviderId = params.providerId?.trim() || buildEndpointIdFromUrl(baseUrl);
|
||||
const baseUrl = params.baseUrl.trim();
|
||||
const explicitProviderId = params.providerId?.trim();
|
||||
if (explicitProviderId && !normalizeEndpointId(explicitProviderId)) {
|
||||
throw new CustomApiError(
|
||||
"invalid_provider_id",
|
||||
"Custom provider ID must include letters, numbers, or hyphens.",
|
||||
);
|
||||
}
|
||||
const requestedProviderId = explicitProviderId || buildEndpointIdFromUrl(baseUrl);
|
||||
const providerIdResult = resolveUniqueEndpointId({
|
||||
requestedId: requestedProviderId,
|
||||
baseUrl,
|
||||
providers,
|
||||
});
|
||||
|
||||
return {
|
||||
providerId: providerIdResult.providerId,
|
||||
...(providerIdResult.renamed
|
||||
? {
|
||||
providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseNonInteractiveCustomApiFlags(
|
||||
params: ParseNonInteractiveCustomApiFlagsParams,
|
||||
): ParsedNonInteractiveCustomApiFlags {
|
||||
const baseUrl = params.baseUrl?.trim() ?? "";
|
||||
const modelId = params.modelId?.trim() ?? "";
|
||||
if (!baseUrl || !modelId) {
|
||||
throw new CustomApiError(
|
||||
"missing_required",
|
||||
[
|
||||
'Auth choice "custom-api-key" requires a base URL and model ID.',
|
||||
"Use --custom-base-url and --custom-model-id.",
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
|
||||
const apiKey = params.apiKey?.trim();
|
||||
const providerId = params.providerId?.trim();
|
||||
if (providerId && !normalizeEndpointId(providerId)) {
|
||||
throw new CustomApiError(
|
||||
"invalid_provider_id",
|
||||
"Custom provider ID must include letters, numbers, or hyphens.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
modelId,
|
||||
compatibility: parseCustomApiCompatibility(params.compatibility),
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
...(providerId ? { providerId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): CustomApiResult {
|
||||
const baseUrl = params.baseUrl.trim();
|
||||
try {
|
||||
new URL(baseUrl);
|
||||
} catch {
|
||||
throw new CustomApiError("invalid_base_url", "Custom provider base URL must be a valid URL.");
|
||||
}
|
||||
|
||||
if (params.compatibility !== "openai" && params.compatibility !== "anthropic") {
|
||||
throw new CustomApiError(
|
||||
"invalid_compatibility",
|
||||
'Custom provider compatibility must be "openai" or "anthropic".',
|
||||
);
|
||||
}
|
||||
|
||||
const modelId = params.modelId.trim();
|
||||
if (!modelId) {
|
||||
throw new CustomApiError("invalid_model_id", "Custom provider model ID is required.");
|
||||
}
|
||||
|
||||
const providerIdResult = resolveCustomProviderId({
|
||||
config: params.config,
|
||||
baseUrl,
|
||||
providerId: params.providerId,
|
||||
});
|
||||
const providerId = providerIdResult.providerId;
|
||||
const providers = params.config.models?.providers ?? {};
|
||||
|
||||
const modelRef = modelKey(providerId, modelId);
|
||||
const alias = params.alias?.trim() ?? "";
|
||||
@@ -290,7 +416,7 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom
|
||||
modelRef,
|
||||
});
|
||||
if (aliasError) {
|
||||
throw new Error(aliasError);
|
||||
throw new CustomApiError("invalid_alias", aliasError);
|
||||
}
|
||||
|
||||
const existingProvider = providers[providerId];
|
||||
@@ -352,10 +478,8 @@ export function applyCustomApiConfig(params: ApplyCustomApiConfigParams): Custom
|
||||
config,
|
||||
providerId,
|
||||
modelId,
|
||||
...(providerIdResult.renamed
|
||||
? {
|
||||
providerIdRenamedFrom: normalizeEndpointId(requestedProviderId) || "custom",
|
||||
}
|
||||
...(providerIdResult.providerIdRenamedFrom
|
||||
? { providerIdRenamedFrom: providerIdResult.providerIdRenamedFrom }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ type EnvSnapshot = {
|
||||
skipCanvas: string | undefined;
|
||||
token: string | undefined;
|
||||
password: string | undefined;
|
||||
customApiKey: string | undefined;
|
||||
disableConfigCache: string | undefined;
|
||||
};
|
||||
|
||||
@@ -39,6 +40,7 @@ function captureEnv(): EnvSnapshot {
|
||||
skipCanvas: process.env.OPENCLAW_SKIP_CANVAS_HOST,
|
||||
token: process.env.OPENCLAW_GATEWAY_TOKEN,
|
||||
password: process.env.OPENCLAW_GATEWAY_PASSWORD,
|
||||
customApiKey: process.env.CUSTOM_API_KEY,
|
||||
disableConfigCache: process.env.OPENCLAW_DISABLE_CONFIG_CACHE,
|
||||
};
|
||||
}
|
||||
@@ -61,6 +63,7 @@ function restoreEnv(prev: EnvSnapshot): void {
|
||||
restoreEnvVar("OPENCLAW_SKIP_CANVAS_HOST", prev.skipCanvas);
|
||||
restoreEnvVar("OPENCLAW_GATEWAY_TOKEN", prev.token);
|
||||
restoreEnvVar("OPENCLAW_GATEWAY_PASSWORD", prev.password);
|
||||
restoreEnvVar("CUSTOM_API_KEY", prev.customApiKey);
|
||||
restoreEnvVar("OPENCLAW_DISABLE_CONFIG_CACHE", prev.disableConfigCache);
|
||||
}
|
||||
|
||||
@@ -77,6 +80,7 @@ async function withOnboardEnv(
|
||||
process.env.OPENCLAW_DISABLE_CONFIG_CACHE = "1";
|
||||
delete process.env.OPENCLAW_GATEWAY_TOKEN;
|
||||
delete process.env.OPENCLAW_GATEWAY_PASSWORD;
|
||||
delete process.env.CUSTOM_API_KEY;
|
||||
|
||||
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
||||
const configPath = path.join(tempHome, "openclaw.json");
|
||||
@@ -408,4 +412,156 @@ describe("onboard (non-interactive): provider auth", () => {
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("uses CUSTOM_API_KEY env fallback for non-interactive custom provider auth", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-env-fallback-",
|
||||
async ({ configPath, runtime }) => {
|
||||
process.env.CUSTOM_API_KEY = "custom-env-key";
|
||||
|
||||
await runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
models?: {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
apiKey?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}>(configPath);
|
||||
|
||||
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
|
||||
"custom-env-key",
|
||||
);
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("uses matching profile fallback for non-interactive custom provider auth", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-profile-fallback-",
|
||||
async ({ configPath, runtime }) => {
|
||||
const { upsertAuthProfile } = await import("../agents/auth-profiles.js");
|
||||
upsertAuthProfile({
|
||||
profileId: "custom-models-custom-local:default",
|
||||
credential: {
|
||||
type: "api_key",
|
||||
provider: "custom-models-custom-local",
|
||||
key: "custom-profile-key",
|
||||
},
|
||||
});
|
||||
|
||||
await runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
);
|
||||
|
||||
const cfg = await readJsonFile<{
|
||||
models?: {
|
||||
providers?: Record<
|
||||
string,
|
||||
{
|
||||
apiKey?: string;
|
||||
}
|
||||
>;
|
||||
};
|
||||
}>(configPath);
|
||||
|
||||
expect(cfg.models?.providers?.["custom-models-custom-local"]?.apiKey).toBe(
|
||||
"custom-profile-key",
|
||||
);
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("fails custom provider auth when compatibility is invalid", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-invalid-compat-",
|
||||
async ({ runtime }) => {
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
customCompatibility: "xmlrpc",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow('Invalid --custom-compatibility (use "openai" or "anthropic").');
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
it("fails custom provider auth when explicit provider id is invalid", async () => {
|
||||
await withOnboardEnv("openclaw-onboard-custom-provider-invalid-id-", async ({ runtime }) => {
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
authChoice: "custom-api-key",
|
||||
customBaseUrl: "https://models.custom.local/v1",
|
||||
customModelId: "local-large",
|
||||
customProviderId: "!!!",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Invalid custom provider config: Custom provider ID must include letters, numbers, or hyphens.",
|
||||
);
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
it("fails inferred custom auth when required flags are incomplete", async () => {
|
||||
await withOnboardEnv(
|
||||
"openclaw-onboard-custom-provider-missing-required-",
|
||||
async ({ runtime }) => {
|
||||
await expect(
|
||||
runNonInteractive(
|
||||
{
|
||||
nonInteractive: true,
|
||||
customApiKey: "custom-test-key",
|
||||
skipHealth: true,
|
||||
skipChannels: true,
|
||||
skipSkills: true,
|
||||
json: true,
|
||||
},
|
||||
runtime,
|
||||
),
|
||||
).rejects.toThrow('Auth choice "custom-api-key" requires a base URL and model ID.');
|
||||
},
|
||||
);
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
@@ -45,9 +45,11 @@ export async function resolveNonInteractiveApiKey(params: {
|
||||
flagValue?: string;
|
||||
flagName: string;
|
||||
envVar: string;
|
||||
envVarName?: string;
|
||||
runtime: RuntimeEnv;
|
||||
agentDir?: string;
|
||||
allowProfile?: boolean;
|
||||
required?: boolean;
|
||||
}): Promise<{ key: string; source: NonInteractiveApiKeySource } | null> {
|
||||
const flagKey = normalizeOptionalSecretInput(params.flagValue);
|
||||
if (flagKey) {
|
||||
@@ -59,6 +61,14 @@ export async function resolveNonInteractiveApiKey(params: {
|
||||
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 (params.allowProfile ?? true) {
|
||||
const profileKey = await resolveApiKeyFromProfiles({
|
||||
provider: params.provider,
|
||||
@@ -70,6 +80,10 @@ export async function resolveNonInteractiveApiKey(params: {
|
||||
}
|
||||
}
|
||||
|
||||
if (params.required === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileHint =
|
||||
params.allowProfile === false ? "" : `, or existing ${params.provider} API-key profile`;
|
||||
params.runtime.error(`Missing ${params.flagName} (or ${params.envVar} in env${profileHint}).`);
|
||||
|
||||
@@ -46,7 +46,12 @@ import {
|
||||
setXiaomiApiKey,
|
||||
setZaiApiKey,
|
||||
} from "../../onboard-auth.js";
|
||||
import { applyCustomApiConfig } from "../../onboard-custom.js";
|
||||
import {
|
||||
applyCustomApiConfig,
|
||||
CustomApiError,
|
||||
parseNonInteractiveCustomApiFlags,
|
||||
resolveCustomProviderId,
|
||||
} from "../../onboard-custom.js";
|
||||
import { applyOpenAIConfig } from "../../openai-model-default.js";
|
||||
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
||||
|
||||
@@ -596,39 +601,37 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
|
||||
if (authChoice === "custom-api-key") {
|
||||
const baseUrl = opts.customBaseUrl?.trim() ?? "";
|
||||
const modelId = opts.customModelId?.trim() ?? "";
|
||||
if (!baseUrl || !modelId) {
|
||||
runtime.error(
|
||||
[
|
||||
'Auth choice "custom-api-key" requires a base URL and model ID.',
|
||||
"Use --custom-base-url and --custom-model-id.",
|
||||
].join("\n"),
|
||||
);
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
|
||||
const compatibilityRaw = opts.customCompatibility?.trim().toLowerCase();
|
||||
let compatibility: "openai" | "anthropic" = "openai";
|
||||
if (compatibilityRaw) {
|
||||
if (compatibilityRaw !== "openai" && compatibilityRaw !== "anthropic") {
|
||||
runtime.error('Invalid --custom-compatibility (use "openai" or "anthropic").');
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
compatibility = compatibilityRaw;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = applyCustomApiConfig({
|
||||
config: nextConfig,
|
||||
baseUrl,
|
||||
modelId,
|
||||
compatibility,
|
||||
const customAuth = parseNonInteractiveCustomApiFlags({
|
||||
baseUrl: opts.customBaseUrl,
|
||||
modelId: opts.customModelId,
|
||||
compatibility: opts.customCompatibility,
|
||||
apiKey: opts.customApiKey,
|
||||
providerId: opts.customProviderId,
|
||||
});
|
||||
const resolvedProviderId = resolveCustomProviderId({
|
||||
config: nextConfig,
|
||||
baseUrl: customAuth.baseUrl,
|
||||
providerId: customAuth.providerId,
|
||||
});
|
||||
const resolvedCustomApiKey = await resolveNonInteractiveApiKey({
|
||||
provider: resolvedProviderId.providerId,
|
||||
cfg: baseConfig,
|
||||
flagValue: customAuth.apiKey,
|
||||
flagName: "--custom-api-key",
|
||||
envVar: "CUSTOM_API_KEY",
|
||||
envVarName: "CUSTOM_API_KEY",
|
||||
runtime,
|
||||
required: false,
|
||||
});
|
||||
const result = applyCustomApiConfig({
|
||||
config: nextConfig,
|
||||
baseUrl: customAuth.baseUrl,
|
||||
modelId: customAuth.modelId,
|
||||
compatibility: customAuth.compatibility,
|
||||
apiKey: resolvedCustomApiKey?.key,
|
||||
providerId: customAuth.providerId,
|
||||
});
|
||||
if (result.providerIdRenamedFrom && result.providerId) {
|
||||
runtime.log(
|
||||
`Custom provider ID "${result.providerIdRenamedFrom}" already exists for a different base URL. Using "${result.providerId}".`,
|
||||
@@ -636,6 +639,19 @@ export async function applyNonInteractiveAuthChoice(params: {
|
||||
}
|
||||
return result.config;
|
||||
} catch (err) {
|
||||
if (err instanceof CustomApiError) {
|
||||
switch (err.code) {
|
||||
case "missing_required":
|
||||
case "invalid_compatibility":
|
||||
runtime.error(err.message);
|
||||
break;
|
||||
default:
|
||||
runtime.error(`Invalid custom provider config: ${err.message}`);
|
||||
break;
|
||||
}
|
||||
runtime.exit(1);
|
||||
return null;
|
||||
}
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
runtime.error(`Invalid custom provider config: ${reason}`);
|
||||
runtime.exit(1);
|
||||
|
||||
Reference in New Issue
Block a user