mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 19:50:43 +00:00
fix(lmstudio): allow keyless local onboarding
This commit is contained in:
@@ -69,6 +69,7 @@ export default definePluginEntry({
|
||||
const providerSetup = await loadProviderSetup();
|
||||
return await providerSetup.promptAndConfigureLmstudioInteractive({
|
||||
config: ctx.config,
|
||||
agentDir: ctx.agentDir,
|
||||
prompter: ctx.prompter,
|
||||
secretInputMode: ctx.secretInputMode,
|
||||
allowSecretRefPrompt: ctx.allowSecretRefPrompt,
|
||||
|
||||
@@ -702,6 +702,126 @@ describe("lmstudio setup", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("interactive setup accepts a blank API key for unauthenticated local LM Studio", async () => {
|
||||
const { prompter, text } = createQueuedWizardPrompterHarness([
|
||||
"http://localhost:1234/api/v1/",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
|
||||
const result = await promptAndConfigureLmstudioInteractive({
|
||||
config: buildConfig(),
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(text).toHaveBeenCalledTimes(3);
|
||||
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({
|
||||
provider: "lmstudio",
|
||||
agentDir: undefined,
|
||||
});
|
||||
expect(result.profiles).toEqual([]);
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
|
||||
models: [
|
||||
{
|
||||
id: "qwen3-8b-instruct",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).not.toHaveProperty("auth");
|
||||
});
|
||||
|
||||
it("interactive setup uses existing Authorization headers when the API key is blank", async () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
lmstudio: {
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "stale-config-key",
|
||||
auth: "api-key",
|
||||
headers: {
|
||||
Authorization: "Bearer proxy-token",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const { prompter } = createQueuedWizardPrompterHarness([
|
||||
"http://localhost:1234/api/v1/",
|
||||
"",
|
||||
"",
|
||||
]);
|
||||
|
||||
const result = await promptAndConfigureLmstudioInteractive({
|
||||
config,
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: undefined,
|
||||
headers: {
|
||||
Authorization: "Bearer proxy-token",
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({
|
||||
provider: "lmstudio",
|
||||
agentDir: undefined,
|
||||
});
|
||||
expect(result.profiles).toEqual([]);
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
api: "openai-completions",
|
||||
headers: {
|
||||
Authorization: "Bearer proxy-token",
|
||||
},
|
||||
models: [
|
||||
{
|
||||
id: "qwen3-8b-instruct",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).not.toHaveProperty("apiKey");
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).not.toHaveProperty("auth");
|
||||
});
|
||||
|
||||
it("interactive setup without a wizard accepts a blank API key for local LM Studio", async () => {
|
||||
const promptText = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce("http://localhost:1234/api/v1/")
|
||||
.mockResolvedValueOnce("");
|
||||
|
||||
const result = await promptAndConfigureLmstudioInteractive({
|
||||
config: buildConfig(),
|
||||
promptText,
|
||||
});
|
||||
|
||||
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
|
||||
baseUrl: "http://localhost:1234/v1",
|
||||
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({
|
||||
provider: "lmstudio",
|
||||
agentDir: undefined,
|
||||
});
|
||||
expect(result.profiles).toEqual([]);
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({
|
||||
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
|
||||
});
|
||||
expect(result.configPatch?.models?.providers?.lmstudio).not.toHaveProperty("auth");
|
||||
});
|
||||
|
||||
it("interactive setup overwrites existing config apiKey during re-auth", async () => {
|
||||
const config = {
|
||||
models: {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
removeProviderAuthProfilesWithLock,
|
||||
buildApiKeyCredential,
|
||||
ensureApiKeyFromEnvOrPrompt,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeOptionalSecretInput,
|
||||
type OpenClawConfig,
|
||||
type SecretInput,
|
||||
@@ -363,6 +364,7 @@ async function discoverLmstudioSetupModels(params: {
|
||||
/** Interactive LM Studio setup with connectivity and model-availability checks. */
|
||||
export async function promptAndConfigureLmstudioInteractive(params: {
|
||||
config: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
prompter?: WizardPrompter;
|
||||
secretInputMode?: SecretInputMode;
|
||||
allowSecretRefPrompt?: boolean;
|
||||
@@ -395,7 +397,7 @@ export async function promptAndConfigureLmstudioInteractive(params: {
|
||||
envLabel: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
|
||||
promptMessage: `${LMSTUDIO_PROVIDER_LABEL} API key`,
|
||||
normalize: (value) => value.trim(),
|
||||
validate: (value) => (value.trim() ? undefined : "Required"),
|
||||
validate: () => undefined,
|
||||
prompter: params.prompter,
|
||||
secretInputMode:
|
||||
params.allowSecretRefPrompt === false
|
||||
@@ -406,30 +408,38 @@ export async function promptAndConfigureLmstudioInteractive(params: {
|
||||
credentialMode = mode;
|
||||
},
|
||||
})
|
||||
: String(
|
||||
await promptText({
|
||||
: (
|
||||
(await promptText({
|
||||
message: `${LMSTUDIO_PROVIDER_LABEL} API key`,
|
||||
placeholder: "sk-...",
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
}),
|
||||
placeholder: "sk-... (leave blank if auth is disabled)",
|
||||
validate: () => undefined,
|
||||
})) ?? ""
|
||||
).trim();
|
||||
const credential = params.prompter
|
||||
? buildApiKeyCredential(
|
||||
PROVIDER_ID,
|
||||
credentialInput ??
|
||||
(implicitRefMode && autoRefEnvKey ? `\${${LMSTUDIO_DEFAULT_API_KEY_ENV_VAR}}` : apiKey),
|
||||
undefined,
|
||||
credentialMode
|
||||
? { secretInputMode: credentialMode }
|
||||
: implicitRefMode && autoRefEnvKey
|
||||
? { secretInputMode: "ref" }
|
||||
: undefined,
|
||||
)
|
||||
: {
|
||||
type: "api_key" as const,
|
||||
provider: PROVIDER_ID,
|
||||
key: apiKey,
|
||||
};
|
||||
const normalizedApiKey = normalizeOptionalSecretInput(apiKey);
|
||||
const credentialSource =
|
||||
credentialInput ??
|
||||
(implicitRefMode && autoRefEnvKey ? `\${${LMSTUDIO_DEFAULT_API_KEY_ENV_VAR}}` : apiKey);
|
||||
const shouldStoreCredential = params.prompter
|
||||
? credentialMode === "ref" || hasConfiguredSecretInput(credentialSource)
|
||||
: normalizedApiKey !== undefined;
|
||||
const credential = shouldStoreCredential
|
||||
? params.prompter
|
||||
? buildApiKeyCredential(
|
||||
PROVIDER_ID,
|
||||
credentialSource,
|
||||
undefined,
|
||||
credentialMode
|
||||
? { secretInputMode: credentialMode }
|
||||
: implicitRefMode && autoRefEnvKey
|
||||
? { secretInputMode: "ref" }
|
||||
: undefined,
|
||||
)
|
||||
: {
|
||||
type: "api_key" as const,
|
||||
provider: PROVIDER_ID,
|
||||
key: normalizedApiKey ?? apiKey,
|
||||
}
|
||||
: undefined;
|
||||
const existingProvider = params.config.models?.providers?.[PROVIDER_ID];
|
||||
// Auth setup updates auth/profile/provider model fields but does not mutate
|
||||
// user-provided header overrides. Runtime request assembly is the source of truth for auth.
|
||||
@@ -439,9 +449,19 @@ export async function promptAndConfigureLmstudioInteractive(params: {
|
||||
env: process.env,
|
||||
headers: persistedHeaders,
|
||||
});
|
||||
const hasAuthorizationHeader = hasLmstudioAuthorizationHeader(resolvedHeaders);
|
||||
const setupDiscoveryApiKey =
|
||||
normalizedApiKey ??
|
||||
(shouldUseLmstudioApiKeyPlaceholder({
|
||||
hasModels: true,
|
||||
resolvedApiKey: undefined,
|
||||
hasAuthorizationHeader,
|
||||
})
|
||||
? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER
|
||||
: undefined);
|
||||
const setupDiscovery = await discoverLmstudioSetupModels({
|
||||
baseUrl,
|
||||
apiKey,
|
||||
apiKey: setupDiscoveryApiKey,
|
||||
...(resolvedHeaders ? { headers: resolvedHeaders } : {}),
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
@@ -475,21 +495,29 @@ export async function promptAndConfigureLmstudioInteractive(params: {
|
||||
const defaultModel = setupDiscovery.value.defaultModel;
|
||||
const persistedApiKey =
|
||||
resolvePersistedLmstudioApiKey({
|
||||
currentApiKey: existingProvider?.apiKey,
|
||||
explicitAuth: resolveLmstudioProviderAuthMode(apiKey),
|
||||
fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
|
||||
currentApiKey: normalizedApiKey ? existingProvider?.apiKey : undefined,
|
||||
explicitAuth: resolveLmstudioProviderAuthMode(normalizedApiKey),
|
||||
fallbackApiKey: normalizedApiKey ? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR : undefined,
|
||||
preferFallbackApiKey: true,
|
||||
hasModels: discoveredModels.length > 0,
|
||||
hasAuthorizationHeader: hasLmstudioAuthorizationHeader(resolvedHeaders),
|
||||
}) ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR;
|
||||
hasAuthorizationHeader,
|
||||
}) ?? (normalizedApiKey ? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR : undefined);
|
||||
if (!credential) {
|
||||
await removeProviderAuthProfilesWithLock({
|
||||
provider: PROVIDER_ID,
|
||||
agentDir: params.agentDir,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: `${PROVIDER_ID}:default`,
|
||||
credential,
|
||||
},
|
||||
],
|
||||
profiles: credential
|
||||
? [
|
||||
{
|
||||
profileId: `${PROVIDER_ID}:default`,
|
||||
credential,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
configPatch: {
|
||||
agents: {
|
||||
defaults: {
|
||||
|
||||
Reference in New Issue
Block a user