From a6a4da70d18d257d41c704c187102fd86ccf004c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 20:42:10 -0700 Subject: [PATCH] refactor(lmstudio): share non-interactive discovery validation (#129808) --- extensions/lmstudio/index.test.ts | 76 +++++++++++++--- extensions/lmstudio/index.ts | 106 ++-------------------- extensions/lmstudio/src/setup.ts | 141 ++++++++++++++++++++---------- 3 files changed, 165 insertions(+), 158 deletions(-) diff --git a/extensions/lmstudio/index.test.ts b/extensions/lmstudio/index.test.ts index 5f63cf3005b9..adcdd9265631 100644 --- a/extensions/lmstudio/index.test.ts +++ b/extensions/lmstudio/index.test.ts @@ -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({ diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts index dd21fac56fa6..4a15d2bc5fdd 100644 --- a/extensions/lmstudio/index.ts +++ b/extensions/lmstudio/index.ts @@ -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 ->[0]; - -async function validateLmstudioNonInteractive( - ctx: LmstudioNonInteractiveValidationContext, -): Promise { - 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 ), 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); diff --git a/extensions/lmstudio/src/setup.ts b/extensions/lmstudio/src/setup.ts index 4dd8dcbc53ca..fdaf130018e6 100644 --- a/extensions/lmstudio/src/setup.ts +++ b/extensions/lmstudio/src/setup.ts @@ -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; + 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 { +async function validateNonInteractiveLmstudioDiscovery( + ctx: Omit, + 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, +): Promise { + return Boolean(await validateNonInteractiveLmstudioDiscovery(ctx, true)); +} + +/** Non-interactive setup path backed by the shared self-hosted helper. */ +export async function configureLmstudioNonInteractive( + ctx: ProviderAuthMethodNonInteractiveContext, +): Promise { + 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;