import { CUSTOM_LOCAL_AUTH_MARKER } from
"openclaw/plugin-sdk/provider-auth" ;
import type { OpenClawConfig } from
"openclaw/plugin-sdk/provider-auth" ;
import type { ModelDefinitionConfig } from
"openclaw/plugin-sdk/provider-model-shared" ;
import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard" ;
import {
SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
type ProviderAuthMethodNonInteractiveContext,
type ProviderCatalogContext,
} from "openclaw/plugin-sdk/provider-setup" ;
import type { WizardPrompter } from "openclaw/plugin-sdk/setup" ;
import { beforeEach, describe, expect, it, vi } from "vitest" ;
import {
LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
} from "./defaults.js" ;
import {
configureLmstudioNonInteractive,
discoverLmstudioProvider,
promptAndConfigureLmstudioInteractive,
} from "./setup.js" ;
const fetchLmstudioModelsMock = vi.hoisted(() => vi.fn());
const discoverLmstudioModelsMock = vi.hoisted(() => vi.fn());
const configureSelfHostedNonInteractiveMock = vi.hoisted(() => vi.fn());
const removeProviderAuthProfilesWithLockMock = vi.hoisted(() => vi.fn());
vi.mock("./models.fetch.js" , () => ({
fetchLmstudioModels: (...args: unknown[]) => fetchLmstudioModelsMock(...args),
discoverLmstudioModels: (...args: unknown[]) => discoverLmstudioModelsMock(...args),
ensureLmstudioModelLoaded: vi.fn(),
}));
vi.mock("openclaw/plugin-sdk/provider-auth" , async (importOriginal) => {
const actual = await importOriginal<typeof import ("openclaw/plugin-sdk/provider-auth" )>();
return {
...actual,
removeProviderAuthProfilesWithLock: (...args: unknown[]) =>
removeProviderAuthProfilesWithLockMock(...args),
};
});
vi.mock("openclaw/plugin-sdk/provider-setup" , async (importOriginal) => {
const actual = await importOriginal<typeof import ("openclaw/plugin-sdk/provider-setup" )>();
return {
...actual,
configureOpenAICompatibleSelfHostedProviderNonInteractive: (...args: unknown[]) =>
configureSelfHostedNonInteractiveMock(...args),
};
});
function createModel(id: string, name = id): ModelDefinitionConfig {
return {
id,
name,
reasoning: false ,
input: ["text" ],
cost: { input: 0 , output: 0 , cacheRead: 0 , cacheWrite: 0 },
contextWindow: 8192 ,
maxTokens: 8192 ,
};
}
function buildConfig(): OpenClawConfig {
return {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
apiKey: "LM_API_TOKEN" ,
api: "openai-completions" ,
models: [],
},
},
},
};
}
function buildDiscoveryContext(params?: {
config?: OpenClawConfig;
apiKey?: string;
discoveryApiKey?: string;
env?: NodeJS.ProcessEnv;
}): ProviderCatalogContext {
return {
config: params?.config ?? ({} as OpenClawConfig),
env: params?.env ?? {},
resolveProviderApiKey: () => ({
apiKey: params?.apiKey,
discoveryApiKey: params?.discoveryApiKey,
}),
resolveProviderAuth: () => ({
apiKey: params?.apiKey,
discoveryApiKey: params?.discoveryApiKey,
mode: "none" as const ,
source: "none" as const ,
}),
};
}
function buildNonInteractiveContext(params?: {
config?: OpenClawConfig;
customBaseUrl?: string;
customApiKey?: string;
lmstudioApiKey?: string;
customModelId?: string;
resolvedApiKey?: string | null ;
resolvedApiKeySource?: "flag" | "env" | "profile" ;
}): ProviderAuthMethodNonInteractiveContext & {
runtime: {
error: ReturnType<typeof vi.fn>;
exit: ReturnType<typeof vi.fn>;
log: ReturnType<typeof vi.fn>;
};
resolveApiKey: ReturnType<typeof vi.fn>;
toApiKeyCredential: ReturnType<typeof vi.fn>;
} {
const error = vi.fn<(...args: unknown[]) => void >();
const exit = vi.fn<(code: number) => void >();
const log = vi.fn<(...args: unknown[]) => void >();
const resolveApiKey = vi.fn(async () =>
params?.resolvedApiKey === null
? null
: {
key: params?.resolvedApiKey ?? "lmstudio-test-key" ,
source: params?.resolvedApiKeySource ?? "flag" ,
},
);
const toApiKeyCredential = vi.fn();
return {
authChoice: "lmstudio" ,
config: params?.config ?? buildConfig(),
baseConfig: params?.config ?? buildConfig(),
opts: {
customBaseUrl: params?.customBaseUrl,
customApiKey: params?.customApiKey ?? "lmstudio-test-key" ,
lmstudioApiKey: params?.lmstudioApiKey,
customModelId: params?.customModelId,
} as ProviderAuthMethodNonInteractiveContext["opts" ],
runtime: { error, exit, log },
resolveApiKey,
toApiKeyCredential,
};
}
function createQueuedWizardPrompterHarness(textValues: string[]): {
prompter: WizardPrompter;
note: ReturnType<typeof vi.fn>;
text: ReturnType<typeof vi.fn>;
} {
const queue = [...textValues];
const note = vi.fn(async (_message: string, _title?: string) => {});
const text = vi.fn(async () => queue.shift() ?? "" );
const prompter: WizardPrompter = {
intro: async () => {},
outro: async () => {},
note,
select: async <T>(params: { options: Array<{ value: T }> }) => {
const firstOption = params.options[0 ];
if (!firstOption) {
throw new Error("select called without options" );
}
return firstOption.value;
},
multiselect: async () => [],
text,
confirm: async () => false ,
progress: () => ({
update: () => {},
stop: () => {},
}),
};
return { prompter, note, text };
}
describe("lmstudio setup" , () => {
beforeEach(() => {
fetchLmstudioModelsMock.mockReset();
discoverLmstudioModelsMock.mockReset();
configureSelfHostedNonInteractiveMock.mockReset();
removeProviderAuthProfilesWithLockMock.mockReset();
fetchLmstudioModelsMock.mockResolvedValue({
reachable: true ,
status: 200 ,
models: [
{
type: "llm" ,
key: "qwen3-8b-instruct" ,
},
],
});
discoverLmstudioModelsMock.mockResolvedValue([createModel("qwen3-8b-instruct" , "Qwen3 8B" )]);
configureSelfHostedNonInteractiveMock.mockImplementation(
async ({
providerId,
ctx,
}: {
providerId: string;
ctx: ProviderAuthMethodNonInteractiveContext;
}) => {
const modelId =
(typeof ctx.opts.customModelId === "string" ? ctx.opts.customModelId.trim() : "" ) ||
"qwen3-8b-instruct" ;
return {
agents: { defaults: { model: { primary: `${providerId}/${modelId}` } } },
models: {
providers: {
[providerId]: { api: "openai-completions" , auth: "api-key" , apiKey: "LM_API_TOKEN" },
},
},
};
},
);
});
it("non-interactive setup discovers catalog and writes LM Studio provider config" , async () => {
const ctx = buildNonInteractiveContext({
customBaseUrl: "http://localhost:1234/api/v1/ ",
customModelId: "qwen3-8b-instruct" ,
});
fetchLmstudioModelsMock.mockResolvedValueOnce({
reachable: true ,
status: 200 ,
models: [
{
type: "llm" ,
key: "qwen3-8b-instruct" ,
display_name: "Qwen3 8B" ,
loaded_instances: [{ id: "inst-1" , config: { context_length: 64000 } }],
},
{
type: "embedding" ,
key: "text-embedding-nomic-embed-text-v1.5" ,
},
],
});
const result = await configureLmstudioNonInteractive(ctx);
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: "lmstudio-test-key" ,
timeoutMs: 5000 ,
});
expect(result?.models?.providers?.lmstudio).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
auth: "api-key" ,
apiKey: "LM_API_TOKEN" ,
models: [
{
id: "qwen3-8b-instruct" ,
contextWindow: SELF_HOSTED_DEFAULT_CONTEXT_WINDOW,
contextTokens: 64000 ,
},
],
});
expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe(
"lmstudio/qwen3-8b-instruct" ,
);
});
it("non-interactive setup preserves existing custom headers when CLI auth is provided" , async () => {
const ctx = buildNonInteractiveContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "LM_API_TOKEN" ,
headers: {
Authorization: "Bearer stale-token" ,
"X-Proxy-Auth" : "proxy-token" ,
},
models: [],
},
},
},
} as OpenClawConfig,
customBaseUrl: "http://localhost:1234/api/v1/ ",
customModelId: "qwen3-8b-instruct" ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(result?.models?.providers?.lmstudio).toMatchObject({
auth: "api-key" ,
apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
headers: {
Authorization: "Bearer stale-token" ,
"X-Proxy-Auth" : "proxy-token" ,
},
});
});
it("non-interactive setup auto-selects a discovered LM Studio model when none is provided" , async () => {
const ctx = buildNonInteractiveContext({
customBaseUrl: "http://localhost:1234/api/v1/ ",
});
fetchLmstudioModelsMock.mockResolvedValueOnce({
reachable: true ,
status: 200 ,
models: [
{
type: "llm" ,
key: "phi-4" ,
max_context_length: 65536 ,
},
{
type: "llm" ,
key: "qwen3-8b-instruct" ,
display_name: "Qwen3 8B" ,
},
],
});
const result = await configureLmstudioNonInteractive(ctx);
expect(configureSelfHostedNonInteractiveMock).toHaveBeenCalledWith(
expect.objectContaining({
ctx: expect.objectContaining({
opts: expect.objectContaining({
customModelId: "phi-4" ,
}),
}),
}),
);
expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe("lmstudio/phi-4" );
expect(result?.models?.providers?.lmstudio?.models).toEqual([
expect.objectContaining({
id: "phi-4" ,
contextWindow: 65536 ,
}),
expect.objectContaining({
id: "qwen3-8b-instruct" ,
}),
]);
});
it("non-interactive setup synthesizes lmstudio-local when API key is missing" , async () => {
const ctx = buildNonInteractiveContext({
customBaseUrl: "http://localhost:1234/api/v1/ ",
customModelId: "qwen3-8b-instruct" ,
resolvedApiKey: null ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
timeoutMs: 5000 ,
});
expect(result?.models?.providers?.lmstudio).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
models: [
{
id: "qwen3-8b-instruct" ,
},
],
});
});
it("non-interactive setup keeps Authorization header auth without writing a synthetic key" , async () => {
const ctx = buildNonInteractiveContext({
config: {
auth: {
profiles: {
"lmstudio:default" : {
provider: "lmstudio" ,
mode: "api_key" ,
},
},
order: {
lmstudio: ["lmstudio:default" ],
},
},
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
apiKey: "stale-config-key" ,
auth: "api-key" ,
api: "openai-completions" ,
headers: {
Authorization: "Bearer proxy-token" ,
},
models: [],
},
},
},
} as OpenClawConfig,
customBaseUrl: "http://localhost:1234/api/v1/ ",
customApiKey: "" ,
customModelId: "qwen3-8b-instruct" ,
resolvedApiKey: null ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({
provider: "lmstudio" ,
agentDir: undefined,
});
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: undefined,
headers: {
Authorization: "Bearer proxy-token" ,
},
timeoutMs: 5000 ,
});
expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled();
expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe(
"lmstudio/qwen3-8b-instruct" ,
);
expect(result?.models?.providers?.lmstudio).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
headers: {
Authorization: "Bearer proxy-token" ,
},
models: [
{
id: "qwen3-8b-instruct" ,
},
],
});
expect(result?.models?.providers?.lmstudio).not.toHaveProperty("apiKey" );
expect(result?.models?.providers?.lmstudio).not.toHaveProperty("auth" );
expect(result?.auth).toBeUndefined();
});
it("non-interactive setup clears stale profile auth before switching to Authorization header auth" , async () => {
const ctx = buildNonInteractiveContext({
config: {
auth: {
profiles: {
"lmstudio:default" : {
provider: "lmstudio" ,
mode: "api_key" ,
},
},
order: {
lmstudio: ["lmstudio:default" ],
},
},
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
apiKey: "stale-config-key" ,
auth: "api-key" ,
api: "openai-completions" ,
headers: {
Authorization: "Bearer proxy-token" ,
},
models: [],
},
},
},
} as OpenClawConfig,
customBaseUrl: "http://localhost:1234/api/v1/ ",
customApiKey: "" ,
customModelId: "qwen3-8b-instruct" ,
resolvedApiKey: "stale-profile-key" ,
resolvedApiKeySource: "profile" ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({
provider: "lmstudio" ,
agentDir: undefined,
});
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: undefined,
headers: {
Authorization: "Bearer proxy-token" ,
},
timeoutMs: 5000 ,
});
expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled();
expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe(
"lmstudio/qwen3-8b-instruct" ,
);
expect(result?.models?.providers?.lmstudio).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
headers: {
Authorization: "Bearer proxy-token" ,
},
models: [
{
id: "qwen3-8b-instruct" ,
},
],
});
expect(result?.models?.providers?.lmstudio).not.toHaveProperty("apiKey" );
expect(result?.models?.providers?.lmstudio).not.toHaveProperty("auth" );
expect(result?.auth).toBeUndefined();
});
it("non-interactive setup clears env fallback auth before switching to Authorization header auth" , async () => {
const ctx = buildNonInteractiveContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
auth: "api-key" ,
api: "openai-completions" ,
headers: {
Authorization: "Bearer proxy-token" ,
},
models: [],
},
},
},
} as OpenClawConfig,
customBaseUrl: "http://localhost:1234/api/v1/ ",
customApiKey: "" ,
customModelId: "qwen3-8b-instruct" ,
resolvedApiKey: "env-fallback-key" ,
resolvedApiKeySource: "env" ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({
provider: "lmstudio" ,
agentDir: undefined,
});
expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: undefined,
headers: {
Authorization: "Bearer proxy-token" ,
},
timeoutMs: 5000 ,
});
expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled();
expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe(
"lmstudio/qwen3-8b-instruct" ,
);
expect(result?.models?.providers?.lmstudio).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
headers: {
Authorization: "Bearer proxy-token" ,
},
models: [
{
id: "qwen3-8b-instruct" ,
},
],
});
expect(result?.models?.providers?.lmstudio).not.toHaveProperty("apiKey" );
expect(result?.models?.providers?.lmstudio).not.toHaveProperty("auth" );
expect(result?.auth).toBeUndefined();
});
it("non-interactive setup prefers --lmstudio-api-key over --custom-api-key" , async () => {
const ctx = buildNonInteractiveContext({
customBaseUrl: "http://localhost:1234/api/v1/ ",
customModelId: "qwen3-8b-instruct" ,
customApiKey: "old-custom-key" ,
lmstudioApiKey: "new-lmstudio-key" ,
});
await configureLmstudioNonInteractive(ctx);
expect(ctx.resolveApiKey).toHaveBeenCalledWith(
expect.objectContaining({
flagValue: "new-lmstudio-key" ,
flagName: "--lmstudio-api-key" ,
}),
);
});
it("non-interactive setup overwrites existing config apiKey during re-auth" , async () => {
const ctx = buildNonInteractiveContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
auth: "api-key" ,
apiKey: "stale-config-key" ,
api: "openai-completions" ,
models: [],
},
},
},
} as OpenClawConfig,
customBaseUrl: "http://localhost:1234/api/v1/ ",
customModelId: "qwen3-8b-instruct" ,
lmstudioApiKey: "fresh-cli-key" ,
resolvedApiKey: "fresh-cli-key" ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(result?.models?.providers?.lmstudio).toMatchObject({
auth: "api-key" ,
apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
});
expect(result?.models?.providers?.lmstudio?.apiKey).not.toBe("stale-config-key" );
});
it("non-interactive setup fails when requested model is missing" , async () => {
const ctx = buildNonInteractiveContext({
customModelId: "missing-model" ,
});
await expect(configureLmstudioNonInteractive(ctx)).resolves.toBeNull();
expect(ctx.runtime.error).toHaveBeenCalledWith(
expect.stringContaining("LM Studio model missing-model was not found" ),
);
expect(ctx.runtime.exit).toHaveBeenCalledWith(1 );
expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled();
});
it("interactive setup canonicalizes base URL and persists provider/default model" , async () => {
const promptText = vi
.fn()
.mockResolvedValueOnce("http://localhost:1234/api/v1/ ")
.mockResolvedValueOnce("lmstudio-test-key" );
const result = await promptAndConfigureLmstudioInteractive({
config: buildConfig(),
promptText,
});
expect(result.configPatch?.models?.mode).toBe("merge" );
expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
auth: "api-key" ,
apiKey: "LM_API_TOKEN" ,
});
expect(result.defaultModel).toBe("lmstudio/qwen3-8b-instruct" );
expect(result.profiles[0 ]).toMatchObject({
profileId: "lmstudio:default" ,
credential: {
type: "api_key" ,
provider: "lmstudio" ,
key: "lmstudio-test-key" ,
},
});
});
it("interactive setup applies an optional preferred context length to all discovered LM Studio models" , async () => {
fetchLmstudioModelsMock.mockResolvedValueOnce({
reachable: true ,
status: 200 ,
models: [
{
type: "llm" ,
key: "phi-4" ,
display_name: "Phi 4" ,
max_context_length: 65536 ,
},
{
type: "llm" ,
key: "qwen3-8b-instruct" ,
display_name: "Qwen3 8B" ,
max_context_length: 32768 ,
},
],
});
const { prompter, text } = createQueuedWizardPrompterHarness([
"http://localhost:1234/api/v1/ ",
"lmstudio-test-key" ,
"4096" ,
]);
const result = await promptAndConfigureLmstudioInteractive({
config: buildConfig(),
prompter,
});
expect(text).toHaveBeenCalledTimes(3 );
expect(result.configPatch?.models?.providers?.lmstudio?.models).toEqual([
expect.objectContaining({
id: "phi-4" ,
contextWindow: 65536 ,
contextTokens: 4096 ,
maxTokens: 4096 ,
}),
expect.objectContaining({
id: "qwen3-8b-instruct" ,
contextWindow: 32768 ,
contextTokens: 4096 ,
maxTokens: 4096 ,
}),
]);
});
it("interactive setup overwrites existing config apiKey during re-auth" , async () => {
const config = {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
auth: "api-key" ,
apiKey: "stale-config-key" ,
api: "openai-completions" ,
models: [],
},
},
},
} as OpenClawConfig;
const promptText = vi
.fn()
.mockResolvedValueOnce("http://localhost:1234/api/v1/ ")
.mockResolvedValueOnce("fresh-prompt-key" );
const result = await promptAndConfigureLmstudioInteractive({
config,
promptText,
});
expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({
auth: "api-key" ,
apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
});
expect(result.configPatch?.models?.providers?.lmstudio?.apiKey).not.toBe("stale-config-key" );
expect(result.profiles[0 ]).toMatchObject({
profileId: "lmstudio:default" ,
credential: {
type: "api_key" ,
provider: "lmstudio" ,
key: "fresh-prompt-key" ,
},
});
});
it("interactive setup preserves existing custom headers when switching to api-key auth" , async () => {
const config = {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "LM_API_TOKEN" ,
headers: {
Authorization: "Bearer stale-token" ,
"X-Proxy-Auth" : "proxy-token" ,
},
models: [],
},
},
},
} as OpenClawConfig;
const promptText = vi
.fn()
.mockResolvedValueOnce("http://localhost:1234/api/v1/ ")
.mockResolvedValueOnce("lmstudio-test-key" );
const result = await promptAndConfigureLmstudioInteractive({
config,
promptText,
});
expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({
auth: "api-key" ,
apiKey: "LM_API_TOKEN" ,
headers: {
Authorization: "Bearer stale-token" ,
"X-Proxy-Auth" : "proxy-token" ,
},
});
});
it("interactive setup preserves existing agent model allowlist entries" , async () => {
const config = {
agents: {
defaults: {
models: {
"anthropic/claude-sonnet-4-6" : {
alias: "Sonnet" ,
},
},
},
},
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "LM_API_TOKEN" ,
models: [],
},
},
},
} as OpenClawConfig;
const promptText = vi
.fn()
.mockResolvedValueOnce("http://localhost:1234/api/v1/ ")
.mockResolvedValueOnce("lmstudio-test-key" );
const result = await promptAndConfigureLmstudioInteractive({
config,
promptText,
});
expect(result.configPatch?.agents?.defaults?.models).toEqual({
"anthropic/claude-sonnet-4-6" : {
alias: "Sonnet" ,
},
"lmstudio/qwen3-8b-instruct" : {},
});
});
it("interactive setup returns clear errors for unreachable/http-empty results" , async () => {
const cases = [
{
name: "unreachable" ,
discovery: { reachable: false , models: [] },
expectedError: "LM Studio not reachable" ,
},
{
name: "http error" ,
discovery: { reachable: true , status: 401 , models: [] },
expectedError: "LM Studio discovery failed (401)" ,
},
{
name: "no llm models" ,
discovery: {
reachable: true ,
status: 200 ,
models: [{ type: "embedding" , key: "text-embedding-nomic-embed-text-v1.5" }],
},
expectedError: "No LM Studio models found" ,
},
];
for (const testCase of cases) {
const promptText = vi
.fn()
.mockResolvedValueOnce("http://localhost:1234/v1 ")
.mockResolvedValueOnce("lmstudio-test-key" );
fetchLmstudioModelsMock.mockResolvedValueOnce(testCase.discovery);
await expect(
promptAndConfigureLmstudioInteractive({
config: buildConfig(),
promptText,
}),
testCase.name,
).rejects.toThrow(testCase.expectedError);
}
});
it.each([
{
name: "injects lmstudio-local for explicit models by default" ,
providerPatch: {},
expectedProviderPatch: {
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
},
},
{
name: "keeps api-key auth backed by default env marker" ,
providerPatch: {
auth: "api-key" ,
},
expectedProviderPatch: {
auth: "api-key" ,
apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
},
},
{
name: "does not inject api-key marker when Authorization header is configured" ,
providerPatch: {
apiKey: "stale-legacy-key" ,
headers: {
Authorization: "Bearer custom-token" ,
},
},
expectedProviderPatch: {
headers: {
Authorization: "Bearer custom-token" ,
},
},
},
{
name: "still injects lmstudio-local when only non-auth headers are configured" ,
providerPatch: {
headers: {
"X-Proxy-Auth" : "proxy-token" ,
},
},
expectedProviderPatch: {
apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER,
headers: {
"X-Proxy-Auth" : "proxy-token" ,
},
},
},
])(
"discoverLmstudioProvider short-circuits explicit models and $name" ,
async ({ providerPatch, expectedProviderPatch }) => {
const explicitModels = [createModel("qwen3-8b-instruct" , "Qwen3 8B" )];
const result = await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/api/v1/ ",
models: explicitModels,
...providerPatch,
},
},
},
} as OpenClawConfig,
}),
);
expect(discoverLmstudioModelsMock).not.toHaveBeenCalled();
expect(result).toEqual({
provider: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
...expectedProviderPatch,
models: explicitModels,
},
});
},
);
it("discoverLmstudioProvider uses resolved key/headers and non-quiet discovery" , async () => {
discoverLmstudioModelsMock.mockResolvedValueOnce([
createModel("qwen3-8b-instruct" , "Qwen3 8B" ),
]);
const result = await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: {
source: "env" ,
provider: "default" ,
id: "LMSTUDIO_DISCOVERY_TOKEN" ,
},
headers: {
"X-Proxy-Auth" : {
source: "env" ,
provider: "default" ,
id: "LMSTUDIO_PROXY_TOKEN" ,
},
},
models: [],
},
},
},
} as OpenClawConfig,
env: {
LMSTUDIO_DISCOVERY_TOKEN: "secretref-lmstudio-key" ,
LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env" ,
},
}),
);
expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: "secretref-lmstudio-key" ,
headers: {
"X-Proxy-Auth" : "proxy-token-from-env" ,
},
quiet: false ,
});
expect(result?.provider.models?.map((model) => model.id)).toEqual(["qwen3-8b-instruct" ]);
});
it("discoverLmstudioProvider returns null for unresolved header refs" , async () => {
const result = await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
headers: {
"X-Proxy-Auth" : {
source: "env" ,
provider: "default" ,
id: "LMSTUDIO_PROXY_TOKEN" ,
},
},
models: [],
},
},
},
} as OpenClawConfig,
env: {},
}),
);
expect(result).toBeNull();
expect(discoverLmstudioModelsMock).not.toHaveBeenCalled();
});
it("discoverLmstudioProvider returns null for an unresolved apiKey ref" , async () => {
const result = await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: {
source: "env" ,
provider: "default" ,
id: "LMSTUDIO_DISCOVERY_TOKEN" ,
},
models: [],
},
},
},
} as OpenClawConfig,
env: {},
}),
);
expect(result).toBeNull();
expect(discoverLmstudioModelsMock).not.toHaveBeenCalled();
});
it("discoverLmstudioProvider uses configured direct apiKey for discovery" , async () => {
discoverLmstudioModelsMock.mockResolvedValueOnce([
createModel("qwen3-8b-instruct" , "Qwen3 8B" ),
]);
await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "configured-direct-key" ,
models: [],
},
},
},
} as OpenClawConfig,
}),
);
expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: "configured-direct-key" ,
headers: undefined,
quiet: false ,
});
});
it("discoverLmstudioProvider prefers resolved discoveryApiKey over configured apiKey" , async () => {
discoverLmstudioModelsMock.mockResolvedValueOnce([
createModel("qwen3-8b-instruct" , "Qwen3 8B" ),
]);
await discoverLmstudioProvider(
buildDiscoveryContext({
discoveryApiKey: "resolved-discovery-key" ,
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "configured-direct-key" ,
models: [],
},
},
},
} as OpenClawConfig,
}),
);
expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: "resolved-discovery-key" ,
headers: undefined,
quiet: false ,
});
});
it("discoverLmstudioProvider suppresses stale discovery apiKey when Authorization header auth is configured" , async () => {
discoverLmstudioModelsMock.mockResolvedValueOnce([
createModel("qwen3-8b-instruct" , "Qwen3 8B" ),
]);
await discoverLmstudioProvider(
buildDiscoveryContext({
discoveryApiKey: "resolved-stale-key" ,
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "configured-direct-key" ,
headers: {
Authorization: "Bearer custom-token" ,
},
models: [],
},
},
},
} as OpenClawConfig,
}),
);
expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: "" ,
headers: {
Authorization: "Bearer custom-token" ,
},
quiet: false ,
});
});
it("discoverLmstudioProvider rewrites stale api-key auth without a persisted key" , async () => {
const result = await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
auth: "api-key" ,
models: [],
},
},
},
} as OpenClawConfig,
}),
);
expect(result?.provider).toMatchObject({
auth: "api-key" ,
apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
models: [expect.objectContaining({ id: "qwen3-8b-instruct" })],
});
});
it("discoverLmstudioProvider drops stale apiKey when Authorization header auth is configured" , async () => {
const result = await discoverLmstudioProvider(
buildDiscoveryContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
apiKey: "stale-legacy-key" ,
headers: {
Authorization: "Bearer custom-token" ,
},
models: [],
},
},
},
} as OpenClawConfig,
}),
);
expect(result?.provider).toMatchObject({
baseUrl: "http://localhost:1234/v1 ",
api: "openai-completions" ,
headers: {
Authorization: "Bearer custom-token" ,
},
models: [expect.objectContaining({ id: "qwen3-8b-instruct" })],
});
expect(result?.provider.apiKey).toBeUndefined();
expect(result?.provider.auth).toBeUndefined();
});
it("discoverLmstudioProvider uses quiet mode and returns null when unconfigured" , async () => {
discoverLmstudioModelsMock.mockResolvedValueOnce([]);
const result = await discoverLmstudioProvider(buildDiscoveryContext());
expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({
baseUrl: "http://localhost:1234/v1 ",
apiKey: "" ,
quiet: true ,
headers: undefined,
});
expect(result).toBeNull();
});
it("non-interactive setup replaces local auth markers when enabling api-key auth" , async () => {
const ctx = buildNonInteractiveContext({
config: {
models: {
providers: {
lmstudio: {
baseUrl: "http://localhost:1234/v1 ",
apiKey: CUSTOM_LOCAL_AUTH_MARKER,
api: "openai-completions" ,
models: [],
},
},
},
} as OpenClawConfig,
customBaseUrl: "http://localhost:1234/api/v1/ ",
customModelId: "qwen3-8b-instruct" ,
});
const result = await configureLmstudioNonInteractive(ctx);
expect(result?.models?.providers?.lmstudio).toMatchObject({
auth: "api-key" ,
apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR,
});
});
});
Messung V0.5 in Prozent C=96 H=99 G=97
¤ Dauer der Verarbeitung: 0.16 Sekunden
(vorverarbeitet am 2026-06-08)
¤
*© Formatika GbR, Deutschland