refactor(lmstudio): share non-interactive discovery validation (#129808)

This commit is contained in:
Peter Steinberger
2026-08-25 20:42:10 -07:00
committed by GitHub
parent c406ef4b4d
commit a6a4da70d1
3 changed files with 165 additions and 158 deletions
+65 -11
View File
@@ -335,6 +335,53 @@ describe("lmstudio plugin", () => {
});
});
it.each([
{
name: "the local placeholder when no credential exists",
resolvedApiKey: null,
expectedApiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
},
{
name: "a preserved credential profile",
resolvedApiKey: { key: "profile-api-key", source: "profile" as const },
expectedApiKey: "profile-api-key",
},
{
name: "an environment credential",
resolvedApiKey: { key: "environment-api-key", source: "env" as const },
expectedApiKey: "environment-api-key",
},
])("uses $name with the post-reset empty config", async ({ resolvedApiKey, expectedApiKey }) => {
fetchLmstudioModelsMock.mockResolvedValue({
reachable: true,
status: 200,
models: [
{
type: "llm",
key: "qwen/qwen3.5-9b",
loaded_instances: [{ id: "qwen", config: { context_length: 32_768 } }],
},
],
});
const ctx = createLmstudioResetValidationContext(
{
customBaseUrl: "http://lmstudio.internal:1234/v1",
customModelId: "qwen/qwen3.5-9b",
},
resolvedApiKey,
);
await expect(requireLmstudioResetValidator()(ctx)).resolves.toBe(true);
expect(fetchLmstudioModelsMock).toHaveBeenCalledExactlyOnceWith({
baseUrl: "http://lmstudio.internal:1234/v1",
apiKey: expectedApiKey,
timeoutMs: 5000,
});
expect(ctx.runtime.exit).not.toHaveBeenCalled();
expect(ctx.config).toEqual({});
});
it("rejects an unreachable LM Studio endpoint before destructive reset", async () => {
fetchLmstudioModelsMock.mockResolvedValue({ reachable: false, models: [] });
const ctx = createLmstudioResetValidationContext({
@@ -349,19 +396,26 @@ describe("lmstudio plugin", () => {
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
});
it("rejects LM Studio authentication failures before destructive reset", async () => {
fetchLmstudioModelsMock.mockResolvedValue({ reachable: true, status: 401, models: [] });
const ctx = createLmstudioResetValidationContext({
customBaseUrl: "http://lmstudio.internal:1234/v1",
});
it.each([401, 403, 404, 408, 425, 429, 500, 503])(
"preserves the existing HTTP %i reset failure guidance",
async (httpStatus) => {
fetchLmstudioModelsMock.mockResolvedValue({
reachable: true,
status: httpStatus,
models: [],
});
const ctx = createLmstudioResetValidationContext({
customBaseUrl: "http://lmstudio.internal:1234/v1",
});
await expect(requireLmstudioResetValidator()(ctx)).resolves.toBe(false);
await expect(requireLmstudioResetValidator()(ctx)).resolves.toBe(false);
expect(ctx.runtime.error).toHaveBeenCalledWith(
"LM Studio returned HTTP 401 while listing models at http://lmstudio.internal:1234/v1.\nCheck the base URL and API key, then re-run setup.",
);
expect(ctx.runtime.exit).toHaveBeenCalledWith(1);
});
expect(ctx.runtime.error).toHaveBeenCalledExactlyOnceWith(
`LM Studio returned HTTP ${httpStatus} while listing models at http://lmstudio.internal:1234/v1.\nCheck the base URL and API key, then re-run setup.`,
);
expect(ctx.runtime.exit).toHaveBeenCalledExactlyOnceWith(1);
},
);
it("rejects a missing requested LM Studio model before destructive reset", async () => {
fetchLmstudioModelsMock.mockResolvedValue({
+6 -100
View File
@@ -5,123 +5,26 @@ import {
type OpenClawConfig,
type OpenClawPluginApi,
type ProviderAuthContext,
type ProviderAuthMethod,
type ProviderAuthMethodNonInteractiveContext,
type ProviderAuthResult,
} from "openclaw/plugin-sdk/plugin-entry";
import {
CUSTOM_LOCAL_AUTH_MARKER,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import { lmstudioMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import {
LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
LMSTUDIO_DEFAULT_INFERENCE_BASE_URL,
LMSTUDIO_DOCKER_HOST_INFERENCE_BASE_URL,
LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
LMSTUDIO_PROVIDER_LABEL,
} from "./src/defaults.js";
import {
normalizeLmstudioConfiguredCatalogEntries,
normalizeLmstudioProviderConfig,
resolveLoadedContextWindow,
resolveLmstudioInferenceBase,
} from "./src/models.js";
import { shouldUseLmstudioSyntheticAuth } from "./src/provider-auth.js";
import { wrapLmstudioInferencePreload } from "./src/stream.js";
const PROVIDER_ID = "lmstudio";
type LmstudioNonInteractiveValidationContext = Parameters<
NonNullable<ProviderAuthMethod["validateNonInteractive"]>
>[0];
async function validateLmstudioNonInteractive(
ctx: LmstudioNonInteractiveValidationContext,
): Promise<boolean> {
const configuredBaseUrl = normalizeOptionalSecretInput(ctx.opts.customBaseUrl);
const dockerSetup = ["1", "true", "yes", "on"].includes(
process.env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "",
);
const baseUrl = resolveLmstudioInferenceBase(
configuredBaseUrl ||
(dockerSetup ? LMSTUDIO_DOCKER_HOST_INFERENCE_BASE_URL : LMSTUDIO_DEFAULT_INFERENCE_BASE_URL),
);
const providerApiKey = normalizeOptionalSecretInput(ctx.opts.lmstudioApiKey);
const resolvedApiKey = await ctx.resolveApiKey({
provider: PROVIDER_ID,
flagValue: providerApiKey ?? normalizeOptionalSecretInput(ctx.opts.customApiKey),
flagName: providerApiKey === undefined ? "--custom-api-key" : "--lmstudio-api-key",
envVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
envVarName: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
required: false,
});
// A reset preflight may inspect the model catalog but must never invoke
// setup, write credentials, load a model, or mutate the model server.
const { fetchLmstudioModels } = await import("./src/models.fetch.js");
const discovery = await fetchLmstudioModels({
baseUrl,
apiKey: resolvedApiKey?.key ?? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
timeoutMs: 5000,
});
if (!discovery.reachable) {
ctx.runtime.error(
`LM Studio could not be reached at ${baseUrl}.\nStart LM Studio (or run lms server start) and re-run setup.`,
);
ctx.runtime.exit(1);
return false;
}
if (discovery.status !== undefined && discovery.status >= 400) {
ctx.runtime.error(
`LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.\nCheck the base URL and API key, then re-run setup.`,
);
ctx.runtime.exit(1);
return false;
}
const installedModels = discovery.models
.filter((model) => model.type === "llm")
.map((model) => model.key?.trim())
.filter((model): model is string => Boolean(model));
const loadedModels = discovery.models
.filter(
(model) =>
model.type === "llm" &&
Boolean(model.key?.trim()) &&
resolveLoadedContextWindow(model) !== null,
)
.map((model) => model.key?.trim())
.filter((model): model is string => Boolean(model));
// Setup matches the requested wire key unchanged. Accepting provider-
// qualified refs here would permit reset before setup rejects the model.
const requestedModel = normalizeOptionalSecretInput(ctx.opts.customModelId);
if (requestedModel && !installedModels.includes(requestedModel)) {
ctx.runtime.error(
`LM Studio model ${requestedModel} was not found at ${baseUrl}.\nAvailable models: ${installedModels.join(", ")}`,
);
ctx.runtime.exit(1);
return false;
}
if (requestedModel && !loadedModels.includes(requestedModel)) {
ctx.runtime.error(
`LM Studio model ${requestedModel} is installed but not loaded at ${baseUrl}.\nLoad that model in LM Studio, then re-run setup.`,
);
ctx.runtime.exit(1);
return false;
}
if (loadedModels.length === 0) {
ctx.runtime.error(
`No loaded LM Studio LLM models were found at ${baseUrl}.\nLoad a model in LM Studio (or run lms load <model>), then re-run setup.`,
);
ctx.runtime.exit(1);
return false;
}
return true;
}
function resolveLmstudioAugmentedCatalogEntries(config: OpenClawConfig | undefined) {
if (!config) {
return [];
@@ -142,7 +45,7 @@ function resolveLmstudioAugmentedCatalogEntries(config: OpenClawConfig | undefin
/** Lazily loads setup helpers so provider wiring stays lightweight at startup. */
async function loadProviderSetup() {
return await import("./api.js");
return await import("./src/setup.js");
}
export default definePluginEntry({
@@ -199,7 +102,10 @@ export default definePluginEntry({
signal: ctx.signal,
});
},
validateNonInteractive: validateLmstudioNonInteractive,
validateNonInteractive: async (ctx) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.validateLmstudioNonInteractive(ctx);
},
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const providerSetup = await loadProviderSetup();
return await providerSetup.configureLmstudioNonInteractive(ctx);
+94 -47
View File
@@ -219,6 +219,8 @@ function collectLoadedLmstudioModelIds(discovery: LmstudioDiscoveryResult): Set<
function resolveLmstudioDiscoveryFailure(params: {
baseUrl: string;
discovery: LmstudioDiscoveryResult;
requestedModelId?: string;
resetPreflight?: boolean;
}): { noteLines: [string, string]; retryLine?: string; reason: string } | null {
const { baseUrl, discovery } = params;
if (!discovery.reachable) {
@@ -240,7 +242,7 @@ function resolveLmstudioDiscoveryFailure(params: {
return {
noteLines: [
`LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.`,
retryable
retryable && !params.resetPreflight
? "Wait for LM Studio to recover, then re-run setup."
: "Check the base URL and API key, then re-run setup.",
],
@@ -248,7 +250,10 @@ function resolveLmstudioDiscoveryFailure(params: {
reason: `LM Studio discovery failed (${discovery.status})`,
};
}
if (collectLoadedLmstudioModelIds(discovery).size === 0) {
if (
!(params.resetPreflight && params.requestedModelId) &&
collectLoadedLmstudioModelIds(discovery).size === 0
) {
return {
noteLines: [
`No loaded LM Studio LLM models were found at ${baseUrl}.`,
@@ -387,6 +392,8 @@ async function discoverLmstudioSetupModels(params: {
baseUrl: string;
apiKey?: string;
headers?: Record<string, string>;
requestedModelId?: string;
resetPreflight?: boolean;
timeoutMs?: number;
}): Promise<
| { value: LmstudioSetupDiscovery }
@@ -401,6 +408,8 @@ async function discoverLmstudioSetupModels(params: {
const failure = resolveLmstudioDiscoveryFailure({
baseUrl: params.baseUrl,
discovery,
requestedModelId: params.requestedModelId,
resetPreflight: params.resetPreflight,
});
if (failure) {
return { failure };
@@ -751,53 +760,31 @@ export async function promptAndConfigureLmstudioInteractive(params: {
};
}
/** Non-interactive setup path backed by the shared self-hosted helper. */
export async function configureLmstudioNonInteractive(
ctx: ProviderAuthMethodNonInteractiveContext,
): Promise<OpenClawConfig | null> {
async function validateNonInteractiveLmstudioDiscovery(
ctx: Omit<ProviderAuthMethodNonInteractiveContext, "toApiKeyCredential">,
resetPreflight = false,
) {
const customBaseUrl = normalizeOptionalSecretInput(ctx.opts.customBaseUrl);
const baseUrl = resolveLmstudioInferenceBase(
customBaseUrl || resolveLmstudioSetupDefaultInferenceBaseUrl(),
);
const normalizedCtx = customBaseUrl
? {
...ctx,
opts: {
...ctx.opts,
customBaseUrl: baseUrl,
},
}
: ctx;
const configureShared = async (configureCtx: ProviderAuthMethodNonInteractiveContext) =>
await configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx: configureCtx,
providerId: PROVIDER_ID,
providerLabel: LMSTUDIO_PROVIDER_LABEL,
defaultBaseUrl: resolveLmstudioSetupDefaultInferenceBaseUrl(),
defaultApiKeyEnvVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: LMSTUDIO_MODEL_PLACEHOLDER,
});
const requestedModelId = normalizeOptionalSecretInput(normalizedCtx.opts.customModelId);
const resolved = await normalizedCtx.resolveApiKey({
const requestedModelId = normalizeOptionalSecretInput(ctx.opts.customModelId);
const providerApiKey = normalizeOptionalSecretInput(ctx.opts.lmstudioApiKey);
const resolved = await ctx.resolveApiKey({
provider: PROVIDER_ID,
flagValue:
normalizeOptionalSecretInput(normalizedCtx.opts.lmstudioApiKey) ??
normalizeOptionalSecretInput(normalizedCtx.opts.customApiKey),
flagName:
normalizeOptionalSecretInput(normalizedCtx.opts.lmstudioApiKey) !== undefined
? "--lmstudio-api-key"
: "--custom-api-key",
flagValue: providerApiKey ?? normalizeOptionalSecretInput(ctx.opts.customApiKey),
flagName: providerApiKey === undefined ? "--custom-api-key" : "--lmstudio-api-key",
envVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
envVarName: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
required: false,
});
const existingProvider = normalizedCtx.config.models?.providers?.[PROVIDER_ID];
const existingProvider = ctx.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.
const persistedHeaders = existingProvider?.headers;
const resolvedHeaders = await resolveLmstudioProviderHeaders({
config: normalizedCtx.config,
config: ctx.config,
env: process.env,
headers: persistedHeaders,
});
@@ -813,21 +800,23 @@ export async function configureLmstudioNonInteractive(
? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER
: undefined);
if (!setupDiscoveryApiKey && !hasAuthorizationHeader) {
normalizedCtx.runtime.error(
ctx.runtime.error(
`LM Studio API key is required. Set ${LMSTUDIO_DEFAULT_API_KEY_ENV_VAR} or pass --lmstudio-api-key.`,
);
normalizedCtx.runtime.exit(1);
ctx.runtime.exit(1);
return null;
}
const setupDiscovery = await discoverLmstudioSetupModels({
baseUrl,
apiKey: setupDiscoveryApiKey,
...(resolvedHeaders ? { headers: resolvedHeaders } : {}),
requestedModelId,
resetPreflight,
timeoutMs: 5000,
});
if ("failure" in setupDiscovery) {
normalizedCtx.runtime.error(setupDiscovery.failure.noteLines.join("\n"));
normalizedCtx.runtime.exit(1);
ctx.runtime.error(setupDiscovery.failure.noteLines.join("\n"));
ctx.runtime.exit(1);
return null;
}
const discoveredModels = setupDiscovery.value.models;
@@ -839,7 +828,7 @@ export async function configureLmstudioNonInteractive(
selectedModelId !== undefined && setupDiscovery.value.loadedModelIds.has(selectedModelId);
if (!selectedModelId || !selectedModel || !selectedModelLoaded) {
const availableModels = discoveredModels.map((model) => model.id).join(", ");
normalizedCtx.runtime.error(
ctx.runtime.error(
requestedModelId && selectedModel && !selectedModelLoaded
? [
`LM Studio model ${requestedModelId} is installed but not loaded at ${baseUrl}.`,
@@ -855,9 +844,60 @@ export async function configureLmstudioNonInteractive(
`Available models: ${availableModels || "(none)"}`,
].join("\n"),
);
normalizedCtx.runtime.exit(1);
ctx.runtime.exit(1);
return null;
}
return {
baseUrl,
customBaseUrl,
discoveredModels,
existingProvider,
persistedHeaders,
resolved,
resolvedHeaders,
selectedModelId,
setupDiscoveryApiKey,
useHeaderOnlyAuth,
};
}
/** Checks endpoint auth and loaded models without mutating config, profiles, or the server. */
export async function validateLmstudioNonInteractive(
ctx: Omit<ProviderAuthMethodNonInteractiveContext, "toApiKeyCredential">,
): Promise<boolean> {
return Boolean(await validateNonInteractiveLmstudioDiscovery(ctx, true));
}
/** Non-interactive setup path backed by the shared self-hosted helper. */
export async function configureLmstudioNonInteractive(
ctx: ProviderAuthMethodNonInteractiveContext,
): Promise<OpenClawConfig | null> {
const validated = await validateNonInteractiveLmstudioDiscovery(ctx);
if (!validated) {
return null;
}
const {
baseUrl,
customBaseUrl,
discoveredModels,
existingProvider,
persistedHeaders,
resolved,
resolvedHeaders,
selectedModelId,
setupDiscoveryApiKey,
useHeaderOnlyAuth,
} = validated;
const normalizedCtx = customBaseUrl
? {
...ctx,
opts: {
...ctx.opts,
customBaseUrl: baseUrl,
},
}
: ctx;
if (useHeaderOnlyAuth) {
await removeProviderAuthProfilesWithLock({
provider: PROVIDER_ID,
@@ -900,13 +940,20 @@ export async function configureLmstudioNonInteractive(
// state and credential storage are handled consistently. The pre-resolved key
// is injected via resolveApiKey to skip a second prompt. The returned config
// is then post-patched below to add the discovered model list and base URL.
const configured = await configureShared({
...normalizedCtx,
opts: {
...normalizedCtx.opts,
customModelId: selectedModelId,
const configured = await configureOpenAICompatibleSelfHostedProviderNonInteractive({
ctx: {
...normalizedCtx,
opts: {
...normalizedCtx.opts,
customModelId: selectedModelId,
},
resolveApiKey: async () => resolvedOrSynthetic,
},
resolveApiKey: async () => resolvedOrSynthetic,
providerId: PROVIDER_ID,
providerLabel: LMSTUDIO_PROVIDER_LABEL,
defaultBaseUrl: resolveLmstudioSetupDefaultInferenceBaseUrl(),
defaultApiKeyEnvVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
modelPlaceholder: LMSTUDIO_MODEL_PLACEHOLDER,
});
if (!configured) {
return null;