diff --git a/.github/labeler.yml b/.github/labeler.yml index f82c50693ccd..8f7722f7fcec 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -265,6 +265,10 @@ - changed-files: - any-glob-to-any-file: - "extensions/kilocode/**" +"extensions: lmstudio": + - changed-files: + - any-glob-to-any-file: + - "extensions/lmstudio/**" "extensions: openai": - changed-files: - any-glob-to-any-file: diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index ebcf7e492908..b8bc0d0d26eb 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -465,6 +465,28 @@ MiniMax is configured via `models.providers` because it uses custom endpoints: See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets. +### LM Studio + +LM Studio ships as a bundled provider plugin which uses the native API: + +- Provider: `lmstudio` +- Auth: `LM_API_TOKEN` (if auth is not toggled on within LM Studio, any placeholder string is acceptable) +- Default inference base URL: `http://localhost:1234/v1` + +Then set a model (replace with one of the IDs returned by `http://localhost:1234/api/v1/models`): + +```json5 +{ + agents: { + defaults: { model: { primary: "lmstudio/openai/gpt-oss-20b" } }, + }, +} +``` + +OpenClaw uses LM Studio's native `/api/v1/models` and `/api/v1/models/load` +for discovery + auto-load, with `/v1/chat/completions` for inference by default. +See [/providers/lmstudio](/providers/lmstudio) for setup and troubleshooting. + ### Ollama Ollama ships as a bundled provider plugin and uses Ollama's native API: @@ -563,7 +585,7 @@ Example (OpenAI‑compatible): providers: { lmstudio: { baseUrl: "http://localhost:1234/v1", - apiKey: "LMSTUDIO_KEY", + apiKey: "LM_API_TOKEN", api: "openai-completions", models: [ { diff --git a/docs/docs.json b/docs/docs.json index 1f1a17c7f84d..8694801f7d88 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1192,6 +1192,7 @@ "providers/huggingface", "providers/kilocode", "providers/litellm", + "providers/lmstudio", "providers/minimax", "providers/mistral", "providers/modelstudio", diff --git a/docs/providers/index.md b/docs/providers/index.md index c0eac53e92b9..e179592029dc 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -37,6 +37,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi - [Hugging Face (Inference)](/providers/huggingface) - [Kilocode](/providers/kilocode) - [LiteLLM (unified gateway)](/providers/litellm) +- [LM Studio (local models)](/providers/lmstudio) - [MiniMax](/providers/minimax) - [Mistral](/providers/mistral) - [Model Studio (Alibaba Cloud)](/providers/modelstudio) diff --git a/docs/providers/lmstudio.md b/docs/providers/lmstudio.md new file mode 100644 index 000000000000..2f7e6bb9d30d --- /dev/null +++ b/docs/providers/lmstudio.md @@ -0,0 +1,146 @@ +--- +summary: "Run OpenClaw with LM Studio" +read_when: + - You want to run OpenClaw with open source models via LM Studio + - You want to set up and configure LM Studio +title: "LM Studio" +--- + +# LM Studio + +LM Studio is a friendly yet powerful app for running open-weight models on your own hardware. It lets you run llama.cpp (GGUF) or MLX models (Apple Silicon). Comes in a GUI package or headless daemon (`llmster`). For product and setup docs, see [lmstudio.ai](https://lmstudio.ai/). + +## Quick start + +1. Install LM Studio (desktop) or `llmster` (headless), then start the local server: + +```bash +curl -fsSL https://lmstudio.ai/install.sh | bash +``` + +2. Start the server + +Make sure you either start the desktop app or run the daemon using the following command: + +```bash +lms daemon up +``` + +```bash +lms server start --port 1234 +``` + +If you are using the app, make sure you have JIT enabled for a smooth experience. Learn more [here](https://lmstudio.ai/docs/developer/core/ttl-and-auto-evict) + +3. OpenClaw requires an LM Studio token value. Set `LM_API_TOKEN`: + +```bash +export LM_API_TOKEN="your-lm-studio-api-token" +``` + +If you don't want to use LM Studio with Authentication, use any non-empty placeholder value: + +```bash +export LM_API_TOKEN="placeholder-key" +``` + +For LM Studio auth setup details, see [LM Studio Authentication](https://lmstudio.ai/docs/developer/core/authentication). + +4. Run onboarding and choose `LM Studio`: + +```bash +openclaw onboard +``` + +5. In onboarding, use the `Default model` prompt to pick your LM Studio model. + +You can also set or change it later: + +```bash +openclaw models set lmstudio/qwen/qwen3.5-9b +``` + +LM Studio model keys follow a `author/model-name` format (e.g. `qwen/qwen3.5-9b`). OpenClaw +model refs prepend the provider name: `lmstudio/qwen/qwen3.5-9b`. You can find the exact key for +a model by running `curl http://localhost:1234/api/v1/models` and looking at the `key` field. + +## Non-interactive onboarding + +Use non-interactive onboarding when you want to script setup (CI, provisioning, remote bootstrap): + +```bash +openclaw onboard \ + --non-interactive \ + --accept-risk \ + --auth-choice lmstudio \ + --custom-base-url http://localhost:1234/v1 \ + --custom-api-key "$LM_API_TOKEN" \ + --custom-model-id qwen/qwen3.5-9b +``` + +`--custom-model-id` takes the model key as returned by LM Studio (e.g. `qwen/qwen3.5-9b`), without +the `lmstudio/` provider prefix. + +If your LM Studio server does not require authentication, OpenClaw non-interactive onboarding still requires +`--custom-api-key` (or `LM_API_TOKEN` in env). For unauthenticated LM Studio servers, pass any non-empty value. + +This writes `models.providers.lmstudio`, sets the default model to +`lmstudio/`, and writes the `lmstudio:default` auth profile. + +## Configuration + +### Explicit configuration + +```json5 +{ + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + apiKey: "${LM_API_TOKEN}", + api: "openai-completions", + models: [ + { + id: "qwen/qwen3-coder-next", + name: "Qwen 3 Coder Next", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + ], + }, + }, + }, +} +``` + +## Troubleshooting + +### LM Studio not detected + +Make sure LM Studio is running and that you set `LM_API_TOKEN` (or any non-empty placeholder for unauthenticated servers): + +```bash +# Start via desktop app, or headless: +lms server start --port 1234 +``` + +Verify the API is accessible: + +```bash +curl http://localhost:1234/api/v1/models +``` + +### Authentication errors (HTTP 401) + +If setup reports HTTP 401, verify your API key: + +- Check that `LM_API_TOKEN` matches the key configured in LM Studio. +- For LM Studio auth setup details, see [LM Studio Authentication](https://lmstudio.ai/docs/developer/core/authentication). +- If your server does not require authentication, use any non-empty placeholder value for `LM_API_TOKEN`. + +### Just-in-time model loading + +LM Studio supports just-in-time (JIT) model loading, where models are loaded on first request. Make sure you have this enabled to avoid 'Model not loaded' errors. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index bfa08e4194bf..94fc8f12f2f5 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -68,6 +68,7 @@ Semantic memory search uses **embedding APIs** when configured for remote provid - `memorySearch.provider = "gemini"` → Gemini embeddings - `memorySearch.provider = "voyage"` → Voyage embeddings - `memorySearch.provider = "mistral"` → Mistral embeddings +- `memorySearch.provider = "lmstudio"` → LM Studio embeddings (local/self-hosted) - `memorySearch.provider = "ollama"` → Ollama embeddings (local/self-hosted; typically no hosted API billing) - Optional fallback to a remote provider if local embeddings fail diff --git a/extensions/lmstudio/README.md b/extensions/lmstudio/README.md new file mode 100644 index 000000000000..1cd1db344bee --- /dev/null +++ b/extensions/lmstudio/README.md @@ -0,0 +1,3 @@ +# LM Studio Provider + +Bundled provider plugin for LM Studio discovery, auto-load, and setup. diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts new file mode 100644 index 000000000000..7ba072a851e6 --- /dev/null +++ b/extensions/lmstudio/index.ts @@ -0,0 +1,91 @@ +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_PROVIDER_LABEL, +} from "openclaw/plugin-sdk/lmstudio-defaults"; +import { + definePluginEntry, + type OpenClawPluginApi, + type ProviderAuthContext, + type ProviderAuthMethodNonInteractiveContext, + type ProviderAuthResult, + type ProviderDiscoveryContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/plugin-entry"; + +const PROVIDER_ID = "lmstudio"; +const cachedDynamicModels = new Map(); + +/** Lazily loads setup helpers so provider wiring stays lightweight at startup. */ +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/lmstudio-setup"); +} + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "LM Studio Provider", + description: "Bundled LM Studio provider plugin", + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "LM Studio", + docsPath: "/providers/lmstudio", + envVars: [LMSTUDIO_DEFAULT_API_KEY_ENV_VAR], + auth: [ + { + id: "custom", + label: LMSTUDIO_PROVIDER_LABEL, + hint: "Local/self-hosted LM Studio server", + kind: "custom", + run: async (ctx: ProviderAuthContext): Promise => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.promptAndConfigureLmstudioInteractive({ + config: ctx.config, + prompter: ctx.prompter, + secretInputMode: ctx.secretInputMode, + allowSecretRefPrompt: ctx.allowSecretRefPrompt, + }); + }, + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureLmstudioNonInteractive(ctx); + }, + }, + ], + discovery: { + // Run after early providers so local LM Studio detection does not dominate resolution. + order: "late", + run: async (ctx: ProviderDiscoveryContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.discoverLmstudioProvider(ctx); + }, + }, + prepareDynamicModel: async (ctx) => { + const providerSetup = await loadProviderSetup(); + cachedDynamicModels.set( + ctx.providerConfig?.baseUrl ?? "", + await providerSetup.prepareLmstudioDynamicModels(ctx), + ); + }, + resolveDynamicModel: (ctx) => + cachedDynamicModels + .get(ctx.providerConfig?.baseUrl ?? "") + ?.find((model) => model.id === ctx.modelId), + wizard: { + setup: { + choiceId: PROVIDER_ID, + choiceLabel: "LM Studio", + choiceHint: "Local/self-hosted LM Studio server", + groupId: PROVIDER_ID, + groupLabel: "LM Studio", + groupHint: "Self-hosted open-weight models", + methodId: "custom", + }, + modelPicker: { + label: "LM Studio (custom)", + hint: "Detect models from LM Studio /api/v1/models", + methodId: "custom", + }, + }, + }); + }, +}); diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json new file mode 100644 index 000000000000..462b3ab385f9 --- /dev/null +++ b/extensions/lmstudio/openclaw.plugin.json @@ -0,0 +1,24 @@ +{ + "id": "lmstudio", + "providers": ["lmstudio"], + "providerAuthEnvVars": { + "lmstudio": ["LM_API_TOKEN"] + }, + "providerAuthChoices": [ + { + "provider": "lmstudio", + "method": "custom", + "choiceId": "lmstudio", + "choiceLabel": "LM Studio", + "choiceHint": "Local/self-hosted LM Studio server", + "groupId": "lmstudio", + "groupLabel": "LM Studio", + "groupHint": "Self-hosted open-weight models" + } + ], + "configSchema": { + "type": "object", + "additionalProperties": false, + "properties": {} + } +} diff --git a/extensions/lmstudio/package.json b/extensions/lmstudio/package.json new file mode 100644 index 000000000000..1f975641d14e --- /dev/null +++ b/extensions/lmstudio/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/lmstudio-provider", + "version": "2026.3.14", + "private": true, + "description": "OpenClaw LM Studio provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/package.json b/package.json index 8a5b186e932c..63210eb3658e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,14 @@ "types": "./dist/plugin-sdk/core.d.ts", "default": "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/lmstudio-defaults": { + "types": "./dist/plugin-sdk/lmstudio-defaults.d.ts", + "default": "./dist/plugin-sdk/lmstudio-defaults.js" + }, + "./plugin-sdk/lmstudio-setup": { + "types": "./dist/plugin-sdk/lmstudio-setup.d.ts", + "default": "./dist/plugin-sdk/lmstudio-setup.js" + }, "./plugin-sdk/ollama-setup": { "types": "./dist/plugin-sdk/ollama-setup.d.ts", "default": "./dist/plugin-sdk/ollama-setup.js" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ef2de073abf..1f178d703f55 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -417,6 +417,8 @@ importers: specifier: ^8.18.0 version: 8.18.0 + extensions/lmstudio: {} + extensions/lobster: dependencies: '@sinclair/typebox': diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index f6b8ecf6bbed..692cbd2fd3ca 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -1,6 +1,8 @@ [ "index", "core", + "lmstudio-defaults", + "lmstudio-setup", "ollama-setup", "provider-setup", "sandbox", diff --git a/src/agents/lmstudio-defaults.ts b/src/agents/lmstudio-defaults.ts new file mode 100644 index 000000000000..d2516698115d --- /dev/null +++ b/src/agents/lmstudio-defaults.ts @@ -0,0 +1,15 @@ +import { LMSTUDIO_LOCAL_AUTH_MARKER } from "./model-auth-markers.js"; + +/** Shared LM Studio defaults used by setup, runtime discovery, and embeddings paths. */ +export const LMSTUDIO_DEFAULT_BASE_URL = "http://localhost:1234"; +export const LMSTUDIO_DEFAULT_INFERENCE_BASE_URL = `${LMSTUDIO_DEFAULT_BASE_URL}/v1`; +export const LMSTUDIO_DEFAULT_EMBEDDING_MODEL = "text-embedding-nomic-embed-text-v1.5"; +export const LMSTUDIO_PROVIDER_LABEL = "LM Studio"; +export const LMSTUDIO_DEFAULT_API_KEY_ENV_VAR = "LM_API_TOKEN"; +// Dedicated LM Studio no-auth marker so remote LM Studio hosts can be treated as keyless when intended. +export const LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER = LMSTUDIO_LOCAL_AUTH_MARKER; +export const LMSTUDIO_MODEL_PLACEHOLDER = "model-key-from-api-v1-models"; +// Default context length sent when requesting LM Studio to load a model. +export const LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH = 64000; +export const LMSTUDIO_DEFAULT_MODEL_ID = "qwen/qwen3.5-9b"; +export const LMSTUDIO_PROVIDER_ID = "lmstudio"; diff --git a/src/agents/lmstudio-models.test.ts b/src/agents/lmstudio-models.test.ts new file mode 100644 index 000000000000..a0363acc0b34 --- /dev/null +++ b/src/agents/lmstudio-models.test.ts @@ -0,0 +1,244 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./lmstudio-defaults.js"; +import { + discoverLmstudioModels, + ensureLmstudioModelLoaded, + resolveLmstudioReasoningCapability, + resolveLmstudioInferenceBase, + resolveLmstudioServerBase, +} from "./lmstudio-models.js"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "./self-hosted-provider-defaults.js"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + }; +}); + +describe("lmstudio-models", () => { + const asFetch = (mock: T) => mock as unknown as typeof fetch; + + afterEach(() => { + fetchWithSsrFGuardMock.mockReset(); + vi.unstubAllGlobals(); + }); + + it("normalizes LM Studio base URLs", () => { + expect(resolveLmstudioServerBase()).toBe("http://localhost:1234"); + expect(resolveLmstudioInferenceBase()).toBe("http://localhost:1234/v1"); + expect(resolveLmstudioServerBase("http://localhost:1234/api/v1")).toBe("http://localhost:1234"); + expect(resolveLmstudioInferenceBase("http://localhost:1234/api/v1")).toBe( + "http://localhost:1234/v1", + ); + }); + + it("resolves reasoning capability for supported and unsupported options", () => { + expect(resolveLmstudioReasoningCapability({ capabilities: undefined })).toBe(false); + expect( + resolveLmstudioReasoningCapability({ + capabilities: { + reasoning: { + allowed_options: ["low", "medium", "high"], + default: "low", + }, + }, + }), + ).toBe(true); + expect( + resolveLmstudioReasoningCapability({ + capabilities: { + reasoning: { + allowed_options: ["off"], + default: "off", + }, + }, + }), + ).toBe(false); + }); + + it("discovers llm models and maps metadata", async () => { + const fetchMock = vi.fn(async (_url: string | URL) => ({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + display_name: "Qwen3 8B", + max_context_length: 262144, + format: "mlx", + capabilities: { + vision: true, + trained_for_tool_use: true, + reasoning: { + allowed_options: ["off", "on"], + default: "on", + }, + }, + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + { + type: "llm", + key: "deepseek-r1", + }, + { + type: "embedding", + key: "text-embedding-nomic-embed-text-v1.5", + }, + { + type: "llm", + key: " ", + }, + ], + }), + })); + + const models = await discoverLmstudioModels({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lm-token", + quiet: false, + fetchImpl: asFetch(fetchMock), + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/api/v1/models", + expect.objectContaining({ + headers: { + Authorization: "Bearer lm-token", + }, + }), + ); + + expect(models).toHaveLength(2); + expect(models[0]).toEqual({ + id: "qwen3-8b-instruct", + name: "Qwen3 8B (MLX, vision, tool-use, loaded)", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 64000, + maxTokens: SELF_HOSTED_DEFAULT_MAX_TOKENS, + }); + expect(models[1]).toEqual({ + id: "deepseek-r1", + name: "deepseek-r1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + maxTokens: SELF_HOSTED_DEFAULT_MAX_TOKENS, + }); + }); + + it("skips model load when already loaded", async () => { + const fetchMock = vi.fn(async (_url: string | URL) => ({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + ], + }), + })); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(calledUrls).not.toContain("http://localhost:1234/api/v1/models/load"); + }); + + it("loads model with clamped context length and merged headers", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (String(url).endsWith("/api/v1/models")) { + return { + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + max_context_length: 32768, + loaded_instances: [], + }, + ], + }), + }; + } + if (String(url).endsWith("/api/v1/models/load")) { + return { + ok: true, + json: async () => ({ status: "loaded" }), + requestInit: init, + }; + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lm-token", + headers: { + "X-Proxy-Auth": "required", + Authorization: "Bearer override", + }, + modelKey: " qwen3-8b-instruct ", + }), + ).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const loadCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/models/load")); + expect(loadCall).toBeDefined(); + expect(loadCall?.[1]).toMatchObject({ + method: "POST", + headers: { + "X-Proxy-Auth": "required", + Authorization: "Bearer lm-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "qwen3-8b-instruct", + context_length: 32768, + }), + }); + const loadInit = loadCall![1] as RequestInit; + expect(typeof loadInit.body).toBe("string"); + const loadBody = JSON.parse(loadInit.body as string) as { context_length: number }; + expect(loadBody.context_length).not.toBe(LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH); + }); + + it("throws when model discovery fails", async () => { + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 401, + })); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).rejects.toThrow("LM Studio model discovery failed (401)"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/agents/lmstudio-models.ts b/src/agents/lmstudio-models.ts new file mode 100644 index 000000000000..5e89d6f984c7 --- /dev/null +++ b/src/agents/lmstudio-models.ts @@ -0,0 +1,467 @@ +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import { fetchWithSsrFGuard } from "../infra/net/fetch-guard.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { + LMSTUDIO_DEFAULT_BASE_URL, + LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, +} from "./lmstudio-defaults.js"; +import { buildLmstudioAuthHeaders } from "./lmstudio-runtime.js"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "./self-hosted-provider-defaults.js"; + +const log = createSubsystemLogger("agents/lmstudio"); + +export type LmstudioModelWire = { + type?: "llm" | "embedding"; + key?: string; + display_name?: string; + max_context_length?: number; + format?: "gguf" | "mlx" | null; + capabilities?: { + vision?: boolean; + trained_for_tool_use?: boolean; + reasoning?: LmstudioReasoningCapabilityWire; + }; + loaded_instances?: Array<{ + id?: string; + config?: { + context_length?: number; + } | null; + } | null>; +}; + +type LmstudioReasoningCapabilityWire = { + allowed_options?: unknown; + default?: unknown; +}; + +type LmstudioModelsResponseWire = { + models?: LmstudioModelWire[]; +}; + +type LmstudioLoadResponse = { + status?: string; +}; + +type FetchLmstudioModelsResult = { + reachable: boolean; + status?: number; + models: LmstudioModelWire[]; + error?: unknown; +}; + +function normalizeReasoningOption(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function isReasoningEnabledOption(value: unknown): boolean { + const normalized = normalizeReasoningOption(value); + if (!normalized) { + return false; + } + return normalized !== "off"; +} + +/** + * Resolves LM Studio reasoning support from capabilities payloads. + * Defaults to false when the server omits reasoning metadata. + */ +export function resolveLmstudioReasoningCapability( + entry: Pick, +): boolean { + const reasoning = entry.capabilities?.reasoning; + if (reasoning === undefined || reasoning === null) { + return false; + } + const allowedOptionsRaw = reasoning.allowed_options; + const allowedOptions = Array.isArray(allowedOptionsRaw) + ? allowedOptionsRaw + .map((option) => normalizeReasoningOption(option)) + .filter((option): option is string => option !== null) + : []; + if (allowedOptions.length > 0) { + return allowedOptions.some((option) => isReasoningEnabledOption(option)); + } + return isReasoningEnabledOption(reasoning.default); +} + +/** + * Reads loaded LM Studio instances and returns the largest valid context window. + * Returns null when no usable loaded context is present. + */ +export function resolveLoadedContextWindow( + entry: Pick, +): number | null { + const loadedInstances = Array.isArray(entry.loaded_instances) ? entry.loaded_instances : []; + let contextWindow: number | null = null; + for (const instance of loadedInstances) { + // Discovery payload is external JSON, so tolerate malformed entries. + const length = instance?.config?.context_length; + if (length === undefined || !Number.isFinite(length) || length <= 0) { + continue; + } + const normalized = Math.floor(length); + contextWindow = contextWindow === null ? normalized : Math.max(contextWindow, normalized); + } + return contextWindow; +} + +/** + * Normalizes a server path by stripping trailing slash and inference suffixes. + * + * LM Studio users often copy their inference URL (e.g. "http://localhost:1234/v1") instead + * of the server root. This function strips a trailing "/v1" or "/api/v1" so the caller always + * receives a clean root base URL. The expected input is the server root without any API version + * path (e.g. "http://localhost:1234"). + */ +function normalizeUrlPath(pathname: string): string { + const trimmed = pathname.replace(/\/+$/, ""); + if (!trimmed) { + return ""; + } + return trimmed.replace(/\/api\/v1$/i, "").replace(/\/v1$/i, ""); +} + +/** Resolves LM Studio server base URL (without /v1 or /api/v1). */ +export function resolveLmstudioServerBase(configuredBaseUrl?: string): string { + // Use configured value when present; otherwise target local LM Studio default. + const configured = configuredBaseUrl?.trim(); + const resolved = configured && configured.length > 0 ? configured : LMSTUDIO_DEFAULT_BASE_URL; + try { + const parsed = new URL(resolved); + const pathname = normalizeUrlPath(parsed.pathname); + parsed.pathname = pathname.length > 0 ? pathname : "/"; + parsed.search = ""; + parsed.hash = ""; + return parsed.toString().replace(/\/$/, ""); + } catch { + const trimmed = resolved.replace(/\/+$/, ""); + const normalized = normalizeUrlPath(trimmed); + return normalized.length > 0 ? normalized : LMSTUDIO_DEFAULT_BASE_URL; + } +} + +/** Resolves LM Studio inference base URL and always appends /v1. */ +export function resolveLmstudioInferenceBase(configuredBaseUrl?: string): string { + const serverBase = resolveLmstudioServerBase(configuredBaseUrl); + return `${serverBase}/v1`; +} + +async function fetchLmstudioEndpoint(params: { + url: string; + init?: RequestInit; + timeoutMs: number; + fetchImpl?: typeof fetch; + ssrfPolicy?: SsrFPolicy; + auditContext: string; +}): Promise<{ response: Response; release: () => Promise }> { + if (params.ssrfPolicy) { + return await fetchWithSsrFGuard({ + url: params.url, + init: params.init, + timeoutMs: params.timeoutMs, + fetchImpl: params.fetchImpl, + policy: params.ssrfPolicy, + auditContext: params.auditContext, + }); + } + const fetchFn = params.fetchImpl ?? fetch; + return { + response: await fetchFn(params.url, { + ...params.init, + signal: AbortSignal.timeout(params.timeoutMs), + }), + release: async () => {}, + }; +} + +/** Fetches /api/v1/models and reports transport reachability separately from HTTP status. */ +export async function fetchLmstudioModels(params: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + ssrfPolicy?: SsrFPolicy; + timeoutMs?: number; + /** Injectable fetch implementation; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +}): Promise { + const baseUrl = resolveLmstudioServerBase(params.baseUrl); + const timeoutMs = params.timeoutMs ?? 5000; + try { + const { response, release } = await fetchLmstudioEndpoint({ + url: `${baseUrl}/api/v1/models`, + init: { + headers: buildLmstudioAuthHeaders({ + apiKey: params.apiKey, + headers: params.headers, + }), + }, + timeoutMs, + fetchImpl: params.fetchImpl, + ssrfPolicy: params.ssrfPolicy, + auditContext: "lmstudio-model-discovery", + }); + try { + if (!response.ok) { + return { + reachable: true, + status: response.status, + models: [], + }; + } + // External service payload is untrusted JSON; parse with a permissive wire type. + const payload = (await response.json()) as LmstudioModelsResponseWire; + return { + reachable: true, + status: response.status, + models: Array.isArray(payload.models) ? payload.models : [], + }; + } finally { + await release(); + } + } catch (error) { + return { + reachable: false, + models: [], + error, + }; + } +} + +function buildLmstudioModelName(model: { + displayName: string; + format: "gguf" | "mlx" | null; + vision: boolean; + trainedForToolUse: boolean; + loaded: boolean; +}): string { + const tags: string[] = []; + if (model.format === "mlx") { + tags.push("MLX"); + } else if (model.format === "gguf") { + tags.push("GGUF"); + } + if (model.vision) { + tags.push("vision"); + } + if (model.trainedForToolUse) { + tags.push("tool-use"); + } + if (model.loaded) { + tags.push("loaded"); + } + if (tags.length === 0) { + return model.displayName; + } + return `${model.displayName} (${tags.join(", ")})`; +} + +/** + * Base model fields extracted from a single LM Studio wire entry. + * Shared by the setup layer (persists simple names to config) and the runtime + * discovery path (which enriches the name with format/state tags). + */ +export type LmstudioModelBase = { + id: string; + displayName: string; + format: "gguf" | "mlx" | null; + vision: boolean; + trainedForToolUse: boolean; + loaded: boolean; + reasoning: boolean; + input: ModelDefinitionConfig["input"]; + cost: ModelDefinitionConfig["cost"]; + contextWindow: number; + maxTokens: number; +}; + +/** + * Maps a single LM Studio wire entry to its base model fields. + * Returns null for non-LLM entries or entries with no usable key. + * + * Shared by both the setup layer (persists simple names to config) and the + * runtime discovery path (which enriches the name with format/state tags via + * buildLmstudioModelName). + */ +export function mapLmstudioWireEntry(entry: LmstudioModelWire): LmstudioModelBase | null { + if (entry.type !== "llm") { + return null; + } + const id = entry.key?.trim() ?? ""; + if (!id) { + return null; + } + const loadedContextWindow = resolveLoadedContextWindow(entry); + const contextWindow = + loadedContextWindow ?? + (entry.max_context_length !== undefined && + Number.isFinite(entry.max_context_length) && + entry.max_context_length > 0 + ? entry.max_context_length + : SELF_HOSTED_DEFAULT_CONTEXT_WINDOW); + const rawDisplayName = entry.display_name?.trim(); + return { + id, + displayName: rawDisplayName && rawDisplayName.length > 0 ? rawDisplayName : id, + format: entry.format ?? null, + vision: entry.capabilities?.vision === true, + trainedForToolUse: entry.capabilities?.trained_for_tool_use === true, + // Use the same validity check as resolveLoadedContextWindow so malformed entries + // like [null, {}] don't produce a false positive "loaded" tag. + loaded: loadedContextWindow !== null, + reasoning: resolveLmstudioReasoningCapability(entry), + input: entry.capabilities?.vision ? ["text", "image"] : ["text"], + cost: SELF_HOSTED_DEFAULT_COST, + contextWindow, + maxTokens: Math.max(1, Math.min(contextWindow, SELF_HOSTED_DEFAULT_MAX_TOKENS)), + }; +} + +type DiscoverLmstudioModelsParams = { + baseUrl: string; + apiKey: string; + headers?: Record; + quiet: boolean; + /** Injectable fetch implementation; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +}; + +/** Discovers LLM models from LM Studio and maps them to OpenClaw model definitions. */ +export async function discoverLmstudioModels( + params: DiscoverLmstudioModelsParams, +): Promise { + const fetched = await fetchLmstudioModels({ + baseUrl: params.baseUrl, + apiKey: params.apiKey, + headers: params.headers, + fetchImpl: params.fetchImpl, + }); + const quiet = params.quiet; + if (!fetched.reachable) { + if (!quiet) { + log.debug(`Failed to discover LM Studio models: ${String(fetched.error)}`); + } + return []; + } + if (fetched.status !== undefined && fetched.status >= 400) { + if (!quiet) { + log.debug(`Failed to discover LM Studio models: ${fetched.status}`); + } + return []; + } + const models = fetched.models; + if (models.length === 0) { + if (!quiet) { + log.debug("No LM Studio models found on local instance"); + } + return []; + } + + return models + .map((entry): ModelDefinitionConfig | null => { + const base = mapLmstudioWireEntry(entry); + if (!base) { + return null; + } + return { + id: base.id, + // Runtime display: include format/vision/tool-use/loaded tags in the name. + name: buildLmstudioModelName(base), + reasoning: base.reasoning, + input: base.input, + cost: base.cost, + contextWindow: base.contextWindow, + maxTokens: base.maxTokens, + }; + }) + .filter((entry): entry is ModelDefinitionConfig => entry !== null); +} + +/** Ensures a model is loaded in LM Studio before first real inference/embedding call. */ +export async function ensureLmstudioModelLoaded(params: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + ssrfPolicy?: SsrFPolicy; + modelKey: string; + timeoutMs?: number; + /** Injectable fetch implementation; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +}): Promise { + const modelKey = params.modelKey.trim(); + if (!modelKey) { + throw new Error("LM Studio model key is required"); + } + + const timeoutMs = params.timeoutMs ?? 30_000; + const baseUrl = resolveLmstudioServerBase(params.baseUrl); + const preflight = await fetchLmstudioModels({ + baseUrl, + apiKey: params.apiKey, + headers: params.headers, + ssrfPolicy: params.ssrfPolicy, + timeoutMs, + fetchImpl: params.fetchImpl, + }); + if (!preflight.reachable) { + throw new Error(`LM Studio model discovery failed: ${String(preflight.error)}`); + } + if (preflight.status !== undefined && preflight.status >= 400) { + throw new Error(`LM Studio model discovery failed (${preflight.status})`); + } + const matchingModel = preflight.models.find((entry) => entry.key?.trim() === modelKey); + if (matchingModel && resolveLoadedContextWindow(matchingModel) !== null) { + return; + } + const advertisedContextLimit = + matchingModel?.max_context_length !== undefined && + Number.isFinite(matchingModel.max_context_length) && + matchingModel.max_context_length > 0 + ? Math.floor(matchingModel.max_context_length) + : null; + const requestedContextLength = + advertisedContextLimit === null + ? LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH + : Math.min(LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, advertisedContextLimit); + + const { response, release } = await fetchLmstudioEndpoint({ + url: `${baseUrl}/api/v1/models/load`, + init: { + method: "POST", + headers: buildLmstudioAuthHeaders({ + apiKey: params.apiKey, + headers: params.headers, + json: true, + }), + body: JSON.stringify({ + model: modelKey, + // Ask LM Studio to load with our default target, capped to the model's own limit. + context_length: requestedContextLength, + }), + }, + timeoutMs, + fetchImpl: params.fetchImpl, + ssrfPolicy: params.ssrfPolicy, + auditContext: "lmstudio-model-load", + }); + try { + if (!response.ok) { + const body = await response.text(); + throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`); + } + const payload = (await response.json()) as LmstudioLoadResponse; + if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") { + throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`); + } + } finally { + await release(); + } +} diff --git a/src/agents/lmstudio-runtime.test.ts b/src/agents/lmstudio-runtime.test.ts new file mode 100644 index 000000000000..b0fe316d6d84 --- /dev/null +++ b/src/agents/lmstudio-runtime.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "./lmstudio-defaults.js"; +import { + buildLmstudioAuthHeaders, + resolveLmstudioConfiguredApiKey, + resolveLmstudioProviderHeaders, + resolveLmstudioRuntimeApiKey, +} from "./lmstudio-runtime.js"; + +const resolveApiKeyForProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("./model-auth.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveApiKeyForProvider: (...args: unknown[]) => resolveApiKeyForProviderMock(...args), + }; +}); + +function buildLmstudioConfig(overrides?: { + apiKey?: unknown; + headers?: unknown; + auth?: "api-key"; +}): OpenClawConfig { + return { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + ...(overrides?.auth ? { auth: overrides.auth } : {}), + ...(overrides?.apiKey !== undefined ? { apiKey: overrides.apiKey } : {}), + ...(overrides?.headers !== undefined ? { headers: overrides.headers } : {}), + models: [], + }, + }, + }, + } as OpenClawConfig; +} + +describe("lmstudio-runtime", () => { + beforeEach(() => { + resolveApiKeyForProviderMock.mockReset(); + }); + + it("falls back to keyless marker for blank runtime auth", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ + apiKey: " ", + source: "profile:lmstudio:default", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: {} as OpenClawConfig, + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("does not synthesize local marker for explicit api-key auth", async () => { + resolveApiKeyForProviderMock.mockRejectedValue( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + allowMissingAuth: true, + }), + ).resolves.toBeUndefined(); + + await expect( + resolveLmstudioConfiguredApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + allowLocalFallback: true, + }), + ).resolves.toBeUndefined(); + }); + + it("treats synthetic local markers as missing in explicit api-key mode", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ + apiKey: "custom-local", + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + allowMissingAuth: true, + }), + ).resolves.toBeUndefined(); + }); + + it("resolves SecretRef api key and headers", async () => { + const headerRef = { + "X-Proxy-Auth": { + source: "env" as const, + provider: "default" as const, + id: "LMSTUDIO_PROXY_TOKEN", + }, + }; + await expect( + resolveLmstudioConfiguredApiKey({ + config: buildLmstudioConfig({ + apiKey: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", + }, + }), + env: { + LM_API_TOKEN: "secretref-lmstudio-key", + }, + }), + ).resolves.toBe("secretref-lmstudio-key"); + + await expect( + resolveLmstudioProviderHeaders({ + config: buildLmstudioConfig({ headers: headerRef }), + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token", + }, + headers: headerRef, + }), + ).resolves.toEqual({ + "X-Proxy-Auth": "proxy-token", + }); + }); + + it("throws a path-specific error when a SecretRef header cannot be resolved", async () => { + const headerRef = { + "X-Proxy-Auth": { + source: "env" as const, + provider: "default" as const, + id: "LMSTUDIO_PROXY_TOKEN", + }, + }; + await expect( + resolveLmstudioProviderHeaders({ + config: buildLmstudioConfig({ headers: headerRef }), + env: {}, + headers: headerRef, + }), + ).rejects.toThrow(/models\.providers\.lmstudio\.headers\.X-Proxy-Auth/i); + }); + + it("builds auth headers with key precedence and json support", () => { + expect(buildLmstudioAuthHeaders({})).toBeUndefined(); + expect(buildLmstudioAuthHeaders({ apiKey: " sk-test " })).toEqual({ + Authorization: "Bearer sk-test", + }); + expect(buildLmstudioAuthHeaders({ apiKey: " " })).toBeUndefined(); + expect( + buildLmstudioAuthHeaders({ apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER }), + ).toBeUndefined(); + expect( + buildLmstudioAuthHeaders({ + apiKey: "sk-new", + json: true, + headers: { + authorization: "Bearer sk-old", + "X-Proxy": "proxy-token", + }, + }), + ).toEqual({ + "Content-Type": "application/json", + "X-Proxy": "proxy-token", + Authorization: "Bearer sk-new", + }); + }); +}); diff --git a/src/agents/lmstudio-runtime.ts b/src/agents/lmstudio-runtime.ts new file mode 100644 index 000000000000..4e47ba5673e1 --- /dev/null +++ b/src/agents/lmstudio-runtime.ts @@ -0,0 +1,197 @@ +import { projectConfigOntoRuntimeSourceSnapshot, type OpenClawConfig } from "../config/config.js"; +import { createConfigRuntimeEnv } from "../config/env-vars.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, LMSTUDIO_PROVIDER_ID } from "./lmstudio-defaults.js"; +import { isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker } from "./model-auth-markers.js"; +import { resolveApiKeyForProvider } from "./model-auth.js"; + +type LmstudioAuthHeadersParams = { + apiKey?: string; + json?: boolean; + headers?: Record; +}; + +export function buildLmstudioAuthHeaders( + params: LmstudioAuthHeadersParams, +): Record | undefined { + const headers: Record = { ...params.headers }; + // Keep auth optional because LM Studio can run without a token. + const apiKey = params.apiKey?.trim(); + if (apiKey && !isNonSecretApiKeyMarker(apiKey)) { + for (const headerName of Object.keys(headers)) { + if (headerName.toLowerCase() === "authorization") { + delete headers[headerName]; + } + } + headers.Authorization = `Bearer ${apiKey}`; + } + if (params.json) { + headers["Content-Type"] = "application/json"; + } + return Object.keys(headers).length > 0 ? headers : undefined; +} + +function sanitizeStringHeaders(headers: unknown): Record | undefined { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return undefined; + } + const next: Record = {}; + for (const [headerName, headerValue] of Object.entries(headers)) { + if (typeof headerValue !== "string") { + continue; + } + const normalized = headerValue.trim(); + if (!normalized) { + continue; + } + next[headerName] = normalized; + } + return Object.keys(next).length > 0 ? next : undefined; +} + +export async function resolveLmstudioConfiguredApiKey(params: { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + allowLocalFallback?: boolean; + path?: string; +}): Promise { + const providerConfig = params.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID]; + const apiKeyInput = providerConfig?.apiKey; + const allowLocalFallback = params.allowLocalFallback === true; + const allowFallbackForProvider = allowLocalFallback && providerConfig?.auth !== "api-key"; + if (apiKeyInput === undefined || apiKeyInput === null) { + return allowFallbackForProvider ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER : undefined; + } + + const directApiKey = normalizeOptionalSecretInput(apiKeyInput); + if (directApiKey !== undefined) { + const trimmed = directApiKey.trim(); + if (!trimmed) { + return allowFallbackForProvider ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER : undefined; + } + if (trimmed === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER) { + return allowFallbackForProvider ? trimmed : undefined; + } + if (isKnownEnvApiKeyMarker(trimmed)) { + return normalizeOptionalSecretInput((params.env ?? process.env)[trimmed]); + } + return isNonSecretApiKeyMarker(trimmed) ? undefined : trimmed; + } + + const sourceConfig = params.config + ? projectConfigOntoRuntimeSourceSnapshot(params.config) + : undefined; + if (!sourceConfig) { + return undefined; + } + const env = createConfigRuntimeEnv(sourceConfig, params.env ?? process.env); + const path = params.path ?? "models.providers.lmstudio.apiKey"; + return await resolveSecretInputString({ + config: sourceConfig, + env, + value: apiKeyInput, + onResolveRefError: (error, ref) => { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `${path}: could not resolve SecretRef "${ref.source}:${ref.provider}:${ref.id}": ${detail}`, + ); + }, + }); +} + +export async function resolveLmstudioProviderHeaders(params: { + config?: OpenClawConfig; + env?: NodeJS.ProcessEnv; + headers?: unknown; + path?: string; +}): Promise | undefined> { + const headerInputs = params.headers; + if (!headerInputs || typeof headerInputs !== "object" || Array.isArray(headerInputs)) { + return undefined; + } + + const sourceConfig = params.config + ? projectConfigOntoRuntimeSourceSnapshot(params.config) + : undefined; + if (!sourceConfig) { + return sanitizeStringHeaders(headerInputs); + } + + const env = createConfigRuntimeEnv(sourceConfig, params.env ?? process.env); + const pathPrefix = params.path ?? "models.providers.lmstudio.headers"; + const resolved: Record = {}; + for (const [headerName, headerValue] of Object.entries(headerInputs)) { + const resolvedValue = await resolveSecretInputString({ + config: sourceConfig, + env, + value: headerValue, + onResolveRefError: (error, ref) => { + const detail = error instanceof Error ? error.message : String(error); + throw new Error( + `${pathPrefix}.${headerName}: could not resolve SecretRef "${ref.source}:${ref.provider}:${ref.id}": ${detail}`, + ); + }, + }); + if (!resolvedValue) { + continue; + } + resolved[headerName] = resolvedValue; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** + * Resolves LM Studio runtime API key from config. + */ +export async function resolveLmstudioRuntimeApiKey(params: { + config?: OpenClawConfig; + agentDir?: string; + allowMissingAuth?: boolean; + env?: NodeJS.ProcessEnv; +}): Promise { + const config = params.config; + if (!config) { + return undefined; + } + let configuredApiKeyPromise: Promise | undefined; + const getConfiguredApiKey = async () => { + configuredApiKeyPromise ??= resolveLmstudioConfiguredApiKey({ + config, + env: params.env, + allowLocalFallback: true, + }); + return await configuredApiKeyPromise; + }; + let resolved: Awaited>; + try { + resolved = await resolveApiKeyForProvider({ + provider: LMSTUDIO_PROVIDER_ID, + cfg: config, + agentDir: params.agentDir, + }); + } catch (error) { + const configuredApiKey = await getConfiguredApiKey(); + if (configuredApiKey) { + return configuredApiKey; + } + if ( + params.allowMissingAuth && + formatErrorMessage(error).includes(`No API key found for provider "${LMSTUDIO_PROVIDER_ID}"`) + ) { + return undefined; + } + throw error; + } + // Normalize empty/whitespace keys to undefined for callers. + const resolvedApiKey = resolved.apiKey?.trim(); + if (!resolvedApiKey || resolvedApiKey.length === 0) { + return await getConfiguredApiKey(); + } + const authMode = config.models?.providers?.[LMSTUDIO_PROVIDER_ID]?.auth; + if (authMode === "api-key" && isNonSecretApiKeyMarker(resolvedApiKey)) { + return await getConfiguredApiKey(); + } + return resolvedApiKey; +} diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index feb0054b302b..7dbc26b43a1f 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -6,7 +6,7 @@ const asConfig = (cfg: OpenClawConfig): OpenClawConfig => cfg; describe("memory search config", () => { function configWithDefaultProvider( - provider: "openai" | "local" | "gemini" | "mistral" | "ollama", + provider: "openai" | "local" | "gemini" | "mistral" | "lmstudio" | "ollama", ): OpenClawConfig { return asConfig({ agents: { @@ -312,6 +312,13 @@ describe("memory search config", () => { expect(resolved?.model).toBe("nomic-embed-text"); }); + it("includes remote defaults and model default for lmstudio without overrides", () => { + const cfg = configWithDefaultProvider("lmstudio"); + const resolved = resolveMemorySearchConfig(cfg, "main"); + expectDefaultRemoteBatch(resolved); + expect(resolved?.model).toBe("text-embedding-nomic-embed-text-v1.5"); + }); + it("defaults session delta thresholds", () => { const cfg = asConfig({ agents: { diff --git a/src/agents/memory-search.ts b/src/agents/memory-search.ts index 1cbc83b77818..e5e7d71e9f4e 100644 --- a/src/agents/memory-search.ts +++ b/src/agents/memory-search.ts @@ -17,7 +17,7 @@ export type ResolvedMemorySearchConfig = { sources: Array<"memory" | "sessions">; extraPaths: string[]; multimodal: MemoryMultimodalSettings; - provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama" | "auto"; + provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "lmstudio" | "ollama" | "auto"; remote?: { baseUrl?: string; apiKey?: SecretInput; @@ -33,7 +33,7 @@ export type ResolvedMemorySearchConfig = { experimental: { sessionMemory: boolean; }; - fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; + fallback: "openai" | "gemini" | "local" | "voyage" | "mistral" | "lmstudio" | "ollama" | "none"; model: string; outputDimensionality?: number; local: { @@ -92,6 +92,7 @@ const DEFAULT_OPENAI_MODEL = "text-embedding-3-small"; const DEFAULT_GEMINI_MODEL = "gemini-embedding-001"; const DEFAULT_VOYAGE_MODEL = "voyage-4-large"; const DEFAULT_MISTRAL_MODEL = "mistral-embed"; +const DEFAULT_LMSTUDIO_MODEL = "text-embedding-nomic-embed-text-v1.5"; const DEFAULT_OLLAMA_MODEL = "nomic-embed-text"; const DEFAULT_CHUNK_TOKENS = 400; const DEFAULT_CHUNK_OVERLAP = 80; @@ -166,6 +167,7 @@ function mergeConfig( provider === "gemini" || provider === "voyage" || provider === "mistral" || + provider === "lmstudio" || provider === "ollama" || provider === "auto"; const batch = { @@ -198,9 +200,11 @@ function mergeConfig( ? DEFAULT_VOYAGE_MODEL : provider === "mistral" ? DEFAULT_MISTRAL_MODEL - : provider === "ollama" - ? DEFAULT_OLLAMA_MODEL - : undefined; + : provider === "lmstudio" + ? DEFAULT_LMSTUDIO_MODEL + : provider === "ollama" + ? DEFAULT_OLLAMA_MODEL + : undefined; const model = overrides?.model ?? defaults?.model ?? modelDefault ?? ""; const outputDimensionality = overrides?.outputDimensionality ?? defaults?.outputDimensionality; const local = { diff --git a/src/agents/model-auth-markers.test.ts b/src/agents/model-auth-markers.test.ts index 96b7aa963174..268215b06437 100644 --- a/src/agents/model-auth-markers.test.ts +++ b/src/agents/model-auth-markers.test.ts @@ -4,6 +4,7 @@ import { GCP_VERTEX_CREDENTIALS_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, + LMSTUDIO_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER, resolveOAuthApiKeyMarker, } from "./model-auth-markers.js"; @@ -15,6 +16,7 @@ describe("model auth markers", () => { expect(isNonSecretApiKeyMarker(resolveOAuthApiKeyMarker("chutes"))).toBe(true); expect(isNonSecretApiKeyMarker("ollama-local")).toBe(true); expect(isNonSecretApiKeyMarker(GCP_VERTEX_CREDENTIALS_MARKER)).toBe(true); + expect(isNonSecretApiKeyMarker(LMSTUDIO_LOCAL_AUTH_MARKER)).toBe(true); }); it("recognizes known env marker names but not arbitrary all-caps keys", () => { diff --git a/src/agents/model-auth-markers.ts b/src/agents/model-auth-markers.ts index 4009630afc85..67d8c1a934d6 100644 --- a/src/agents/model-auth-markers.ts +++ b/src/agents/model-auth-markers.ts @@ -7,6 +7,7 @@ export const QWEN_OAUTH_MARKER = "qwen-oauth"; export const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local"; export const CUSTOM_LOCAL_AUTH_MARKER = "custom-local"; export const GCP_VERTEX_CREDENTIALS_MARKER = "gcp-vertex-credentials"; +export const LMSTUDIO_LOCAL_AUTH_MARKER = "lmstudio-local"; export const NON_ENV_SECRETREF_MARKER = "secretref-managed"; // pragma: allowlist secret export const SECRETREF_ENV_HEADER_MARKER_PREFIX = "secretref-env:"; // pragma: allowlist secret @@ -85,6 +86,7 @@ export function isNonSecretApiKeyMarker( trimmed === OLLAMA_LOCAL_AUTH_MARKER || trimmed === CUSTOM_LOCAL_AUTH_MARKER || trimmed === GCP_VERTEX_CREDENTIALS_MARKER || + trimmed === LMSTUDIO_LOCAL_AUTH_MARKER || trimmed === NON_ENV_SECRETREF_MARKER || isAwsSdkAuthMarker(trimmed); if (isKnownMarker) { diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts index 3949a4655a5c..648852519318 100644 --- a/src/agents/model-auth.test.ts +++ b/src/agents/model-auth.test.ts @@ -5,6 +5,7 @@ import type { AuthProfileStore } from "./auth-profiles.js"; import { CUSTOM_LOCAL_AUTH_MARKER, GCP_VERTEX_CREDENTIALS_MARKER, + LMSTUDIO_LOCAL_AUTH_MARKER, NON_ENV_SECRETREF_MARKER, } from "./model-auth-markers.js"; import { @@ -191,6 +192,48 @@ describe("resolveUsableCustomProviderApiKey", () => { expect(resolved).toBeNull(); }); + it("treats LM Studio no-auth markers as usable for the LM Studio provider", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + lmstudio: { + baseUrl: "http://lmstudio.internal:1234/v1", + apiKey: LMSTUDIO_LOCAL_AUTH_MARKER, + models: [], + }, + }, + }, + }, + provider: "lmstudio", + }); + expect(resolved).toEqual({ + apiKey: LMSTUDIO_LOCAL_AUTH_MARKER, + source: "models.json", + }); + }); + + it("treats legacy custom-local marker as usable for LM Studio provider compatibility", () => { + const resolved = resolveUsableCustomProviderApiKey({ + cfg: { + models: { + providers: { + lmstudio: { + baseUrl: "http://lmstudio.internal:1234/v1", + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + models: [], + }, + }, + }, + }, + provider: "lmstudio", + }); + expect(resolved).toEqual({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + source: "models.json", + }); + }); + it("resolves known env marker names from process env for custom providers", () => { const previous = process.env.OPENAI_API_KEY; process.env.OPENAI_API_KEY = "sk-from-env"; // pragma: allowlist secret @@ -251,6 +294,36 @@ describe("resolveUsableCustomProviderApiKey", () => { }); describe("resolveApiKeyForProvider – synthetic local auth for custom providers", () => { + it("accepts LM Studio no-auth marker for non-local LM Studio providers", async () => { + const auth = await resolveApiKeyForProvider({ + provider: "lmstudio", + cfg: { + models: { + providers: { + lmstudio: { + baseUrl: "http://lmstudio.internal:1234/v1", + api: "openai-completions", + apiKey: LMSTUDIO_LOCAL_AUTH_MARKER, + models: [ + { + id: "qwen-3.5", + name: "Qwen 3.5", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + }, + ], + }, + }, + }, + }, + }); + expect(auth.apiKey).toBe(LMSTUDIO_LOCAL_AUTH_MARKER); + expect(auth.source).toBe("models.json"); + }); + it("synthesizes a local auth marker for custom providers with a local baseUrl and no apiKey", async () => { const auth = await resolveApiKeyForProvider({ provider: "custom-127-0-0-1-8080", @@ -581,4 +654,62 @@ describe("applyLocalNoAuthHeaderOverride", () => { expect(capturedAuthorization).toBeNull(); expect(capturedXTest).toBe("1"); }); + + it("clears Authorization for LM Studio no-auth marker on OpenAI-compatible models", async () => { + let capturedAuthorization: string | null | undefined; + let resolveRequest: (() => void) | undefined; + const requestSeen = new Promise((resolve) => { + resolveRequest = resolve; + }); + globalThis.fetch = withFetchPreconnect( + vi.fn(async (_input, init) => { + const headers = new Headers(init?.headers); + capturedAuthorization = headers.get("Authorization"); + resolveRequest?.(); + return new Response(JSON.stringify({ error: { message: "unauthorized" } }), { + status: 401, + headers: { "content-type": "application/json" }, + }); + }), + ); + + const model = applyLocalNoAuthHeaderOverride( + { + id: "lmstudio", + name: "lmstudio", + api: "openai-completions", + provider: "lmstudio", + baseUrl: "http://lmstudio.internal:1234/v1", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 8192, + maxTokens: 4096, + } as Model<"openai-completions">, + { + apiKey: LMSTUDIO_LOCAL_AUTH_MARKER, + source: "models.providers.lmstudio", + mode: "api-key", + }, + ); + + streamSimpleOpenAICompletions( + model, + { + messages: [ + { + role: "user", + content: "hello", + timestamp: Date.now(), + }, + ], + }, + { + apiKey: LMSTUDIO_LOCAL_AUTH_MARKER, + }, + ); + + await requestSeen; + expect(capturedAuthorization).toBeNull(); + }); }); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 684f20145bd2..a21609a151cf 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -25,6 +25,7 @@ import { CUSTOM_LOCAL_AUTH_MARKER, isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, + LMSTUDIO_LOCAL_AUTH_MARKER, OLLAMA_LOCAL_AUTH_MARKER, } from "./model-auth-markers.js"; import { normalizeProviderId } from "./model-selection.js"; @@ -81,6 +82,13 @@ export function resolveUsableCustomProviderApiKey(params: { if (!customKey) { return null; } + const normalizedProvider = normalizeProviderId(params.provider); + if ( + normalizedProvider === "lmstudio" && + (customKey === LMSTUDIO_LOCAL_AUTH_MARKER || customKey === CUSTOM_LOCAL_AUTH_MARKER) + ) { + return { apiKey: customKey, source: "models.json" }; + } if (!isNonSecretApiKeyMarker(customKey)) { return { apiKey: customKey, source: "models.json" }; } @@ -346,10 +354,17 @@ export async function resolveApiKeyForProvider(params: { const customKey = resolveUsableCustomProviderApiKey({ cfg, provider }); if (customKey) { - return { apiKey: customKey.apiKey, source: customKey.source, mode: "api-key" }; + return { + apiKey: customKey.apiKey, + source: customKey.source, + mode: "api-key", + }; } - const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ cfg, provider }); + const syntheticLocalAuth = resolveSyntheticLocalProviderAuth({ + cfg, + provider, + }); if (syntheticLocalAuth) { return syntheticLocalAuth; } @@ -537,7 +552,10 @@ export function applyLocalNoAuthHeaderOverride>( model: T, auth: ResolvedProviderAuth | null | undefined, ): T { - if (auth?.apiKey !== CUSTOM_LOCAL_AUTH_MARKER || model.api !== "openai-completions") { + const apiKey = auth?.apiKey?.trim(); + const isLocalNoAuthMarker = + apiKey === CUSTOM_LOCAL_AUTH_MARKER || apiKey === LMSTUDIO_LOCAL_AUTH_MARKER; + if (!isLocalNoAuthMarker || model.api !== "openai-completions") { return model; } diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts index e8bec0d704e5..06c2c6ca94cc 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -1,6 +1,7 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { discoverLmstudioModels, resolveLmstudioInferenceBase } from "./lmstudio-models.js"; import { enrichOllamaModelsWithContext, OLLAMA_DEFAULT_CONTEXT_WINDOW, @@ -121,7 +122,9 @@ async function discoverOpenAICompatibleLocalModels(params: { } return models - .map((model) => ({ id: typeof model.id === "string" ? model.id.trim() : "" })) + .map((model) => ({ + id: typeof model.id === "string" ? model.id.trim() : "", + })) .filter((model) => Boolean(model.id)) .map((model) => { const modelId = model.id; @@ -170,6 +173,29 @@ export async function buildVllmProvider(params?: { }; } +export async function buildLmstudioProvider(params?: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + quiet?: boolean; +}): Promise { + const baseUrl = resolveLmstudioInferenceBase(params?.baseUrl); + // Skip real network I/O in test environments to keep provider-discovery tests fast and deterministic. + if (process.env.VITEST || process.env.NODE_ENV === "test") { + return { baseUrl, api: "openai-completions", models: [] }; + } + return { + baseUrl, + api: "openai-completions", + models: await discoverLmstudioModels({ + baseUrl, + apiKey: params?.apiKey ?? "", + headers: params?.headers, + quiet: params?.quiet ?? false, + }), + }; +} + export async function buildSglangProvider(params?: { baseUrl?: string; apiKey?: string; diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index b39705d8ec2d..d11fbc4ba469 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -173,4 +173,23 @@ describe("normalizeProviders", () => { expect((enforced as Record).openai).toBeNull(); expect(enforced?.moonshot?.apiKey).toBe("MOONSHOT_API_KEY"); // pragma: allowlist secret }); + + it("canonicalizes LM Studio baseUrl after merge-style explicit overwrite", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-agent-")); + try { + const providers: NonNullable["providers"]> = { + lmstudio: { + baseUrl: "http://localhost:1234/api/v1/", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + models: [], + }, + }; + + const normalized = normalizeProviders({ providers, agentDir }); + expect(normalized?.lmstudio?.baseUrl).toBe("http://localhost:1234/v1"); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 8b15351faad6..13265b6c24c9 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -19,6 +19,7 @@ import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; +import { resolveLmstudioInferenceBase } from "./lmstudio-models.js"; import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; export { @@ -589,6 +590,17 @@ export function normalizeProviders(params: { normalizedProvider = antigravityNormalized; } + if (normalizedKey === "lmstudio") { + const normalizedBaseUrl = resolveLmstudioInferenceBase(normalizedProvider.baseUrl); + if (normalizedBaseUrl !== normalizedProvider.baseUrl) { + mutated = true; + normalizedProvider = { + ...normalizedProvider, + baseUrl: normalizedBaseUrl, + }; + } + } + const existing = next[normalizedKey]; if (existing) { // Keep deterministic behavior if users accidentally define duplicate diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 529787f21543..0bc8b5e813ad 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js"; const note = vi.hoisted(() => vi.fn()); const resolveDefaultAgentId = vi.hoisted(() => vi.fn(() => "agent-default")); const resolveAgentDir = vi.hoisted(() => vi.fn(() => "/tmp/agent-default")); +const resolveLmstudioRuntimeApiKey = vi.hoisted(() => vi.fn()); const resolveMemorySearchConfig = vi.hoisted(() => vi.fn()); const resolveApiKeyForProvider = vi.hoisted(() => vi.fn()); const resolveMemoryBackendConfig = vi.hoisted(() => vi.fn()); @@ -22,6 +23,10 @@ vi.mock("../agents/memory-search.js", () => ({ resolveMemorySearchConfig, })); +vi.mock("../agents/lmstudio-runtime.js", () => ({ + resolveLmstudioRuntimeApiKey, +})); + vi.mock("../agents/model-auth.js", () => ({ resolveApiKeyForProvider, })); @@ -35,6 +40,18 @@ import { detectLegacyWorkspaceDirs } from "./doctor-workspace.js"; describe("noteMemorySearchHealth", () => { const cfg = {} as OpenClawConfig; + const cfgWithLmstudioApiKeyAuth = { + models: { + providers: { + lmstudio: { + auth: "api-key", + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [], + }, + }, + }, + } as OpenClawConfig; async function expectNoWarningWithConfiguredRemoteApiKey(provider: string) { resolveMemorySearchConfig.mockReturnValue({ @@ -53,11 +70,15 @@ describe("noteMemorySearchHealth", () => { note.mockClear(); resolveDefaultAgentId.mockClear(); resolveAgentDir.mockClear(); + resolveLmstudioRuntimeApiKey.mockReset(); resolveMemorySearchConfig.mockReset(); resolveApiKeyForProvider.mockReset(); resolveApiKeyForProvider.mockRejectedValue(new Error("missing key")); resolveMemoryBackendConfig.mockReset(); - resolveMemoryBackendConfig.mockReturnValue({ backend: "builtin", citations: "auto" }); + resolveMemoryBackendConfig.mockReturnValue({ + backend: "builtin", + citations: "auto", + }); }); it("does not warn when local provider is set with no explicit modelPath (default model fallback)", async () => { @@ -80,7 +101,11 @@ describe("noteMemorySearchHealth", () => { }); await noteMemorySearchHealth(cfg, { - gatewayMemoryProbe: { checked: true, ready: false, error: "node-llama-cpp not installed" }, + gatewayMemoryProbe: { + checked: true, + ready: false, + error: "node-llama-cpp not installed", + }, }); expect(note).toHaveBeenCalledTimes(1); @@ -213,6 +238,139 @@ describe("noteMemorySearchHealth", () => { expect(note).not.toHaveBeenCalled(); }); + it("does not warn when lmstudio is configured without provider auth and the gateway probe is not failing", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg); + + expect(resolveLmstudioRuntimeApiKey).not.toHaveBeenCalled(); + expect(resolveApiKeyForProvider).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalled(); + }); + + it("does not warn when lmstudio provider auth is resolved and the gateway probe is not failing", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + resolveLmstudioRuntimeApiKey.mockResolvedValue("lmstudio-key"); + + await noteMemorySearchHealth(cfgWithLmstudioApiKeyAuth); + + expect(resolveLmstudioRuntimeApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: "/tmp/agent-default", + allowMissingAuth: true, + config: expect.objectContaining({ + models: expect.objectContaining({ + providers: expect.objectContaining({ + lmstudio: expect.objectContaining({ auth: "api-key" }), + }), + }), + }), + }), + ); + expect(note).not.toHaveBeenCalled(); + }); + + it("warns when lmstudio auth mode is api-key and no auth resolves", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + resolveLmstudioRuntimeApiKey.mockResolvedValue(undefined); + + await noteMemorySearchHealth(cfgWithLmstudioApiKeyAuth); + + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain('Memory search provider "lmstudio" is configured for API-key auth'); + expect(message).toContain("LM_API_TOKEN"); + expect(message).toContain("openclaw configure --section model"); + }); + + it("warns when lmstudio auth mode is api-key and only memorySearch remote apiKey is set", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: { + apiKey: "stale-remote-key", + }, + }); + resolveLmstudioRuntimeApiKey.mockResolvedValue(undefined); + + await noteMemorySearchHealth(cfgWithLmstudioApiKeyAuth); + + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain('Memory search provider "lmstudio" is configured for API-key auth'); + expect(message).toContain("LM_API_TOKEN"); + expect(message).toContain("openclaw configure --section model"); + }); + + it("warns when lmstudio gateway probe is not ready and auth mode is api-key", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: { + baseUrl: "https://lmstudio.example.com/v1", + }, + }); + resolveLmstudioRuntimeApiKey.mockResolvedValue(undefined); + + await noteMemorySearchHealth(cfgWithLmstudioApiKeyAuth, { + gatewayMemoryProbe: { + checked: true, + ready: false, + error: "LM Studio model discovery failed (401)", + }, + }); + + expect(resolveLmstudioRuntimeApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + agentDir: "/tmp/agent-default", + allowMissingAuth: true, + config: expect.objectContaining({ + models: expect.objectContaining({ + providers: expect.objectContaining({ + lmstudio: expect.objectContaining({ auth: "api-key" }), + }), + }), + }), + }), + ); + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain('Memory search provider "lmstudio" is configured for API-key auth'); + expect(message).toContain("LM_API_TOKEN"); + expect(message).toContain("Gateway memory probe for default agent is not ready"); + }); + + it("warns about lmstudio probe failures without auth guidance when auth mode is unset", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg, { + gatewayMemoryProbe: { + checked: true, + ready: false, + error: "LM Studio model discovery failed (500)", + }, + }); + + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain( + 'Memory search provider "lmstudio" is configured, but the gateway reports embeddings are not ready.', + ); + expect(message).not.toContain("LM_API_TOKEN"); + }); + it("notes when gateway probe reports embeddings ready and CLI API key is missing", async () => { resolveMemorySearchConfig.mockReturnValue({ provider: "gemini", diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 34f75dcfc11f..4c50afe791a5 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -1,5 +1,6 @@ import fsSync from "node:fs"; import { resolveAgentDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { resolveLmstudioRuntimeApiKey } from "../agents/lmstudio-runtime.js"; import { resolveMemorySearchConfig } from "../agents/memory-search.js"; import { resolveApiKeyForProvider } from "../agents/model-auth.js"; import { formatCliCommand } from "../cli/command-format.js"; @@ -79,6 +80,45 @@ export async function noteMemorySearchHealth( ); return; } + if (resolved.provider === "lmstudio") { + const lmstudioAuthEnabled = cfg.models?.providers?.lmstudio?.auth === "api-key"; + const gatewayProbe = opts?.gatewayMemoryProbe; + const gatewayProbeWarning = buildGatewayProbeWarning(gatewayProbe); + if (!lmstudioAuthEnabled && !gatewayProbeWarning) { + return; + } + const hasLmstudioAuth = await hasApiKeyForProvider(resolved.provider, cfg, agentDir); + if (lmstudioAuthEnabled && !hasLmstudioAuth) { + note( + [ + 'Memory search provider "lmstudio" is configured for API-key auth, but no LM Studio API key was found.', + gatewayProbe?.checked && gatewayProbe.ready + ? "Gateway reports embeddings are ready for the default agent." + : gatewayProbeWarning, + `Fix: set LM_API_TOKEN or run ${formatCliCommand("openclaw configure --section model")}.`, + `Verify: ${formatCliCommand("openclaw memory status --deep")}`, + ] + .filter(Boolean) + .join("\n"), + "Memory search", + ); + return; + } + if (!gatewayProbeWarning) { + return; + } + note( + [ + 'Memory search provider "lmstudio" is configured, but the gateway reports embeddings are not ready.', + gatewayProbeWarning, + `Verify: ${formatCliCommand("openclaw memory status --deep")}`, + ] + .filter(Boolean) + .join("\n"), + "Memory search", + ); + return; + } // Remote provider — check for API key if (hasRemoteApiKey || (await hasApiKeyForProvider(resolved.provider, cfg, agentDir))) { return; @@ -187,13 +227,22 @@ function hasLocalEmbeddings(local: { modelPath?: string }, useDefaultFallback = } async function hasApiKeyForProvider( - provider: "openai" | "gemini" | "voyage" | "mistral" | "ollama", + provider: "openai" | "gemini" | "voyage" | "mistral" | "lmstudio" | "ollama", cfg: OpenClawConfig, agentDir: string, ): Promise { // Map embedding provider names to model-auth provider names const authProvider = provider === "gemini" ? "google" : provider; try { + if (provider === "lmstudio") { + return Boolean( + await resolveLmstudioRuntimeApiKey({ + config: cfg, + agentDir, + allowMissingAuth: true, + }), + ); + } await resolveApiKeyForProvider({ provider: authProvider, cfg, agentDir }); return true; } catch { @@ -209,6 +258,8 @@ function providerEnvVar(provider: string): string { return "GEMINI_API_KEY"; case "voyage": return "VOYAGE_API_KEY"; + case "lmstudio": + return "LM_API_TOKEN"; default: return `${provider.toUpperCase()}_API_KEY`; } diff --git a/src/commands/lmstudio-setup.test.ts b/src/commands/lmstudio-setup.test.ts new file mode 100644 index 000000000000..1d9f5b73759d --- /dev/null +++ b/src/commands/lmstudio-setup.test.ts @@ -0,0 +1,540 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, +} from "../agents/lmstudio-defaults.js"; +import { CUSTOM_LOCAL_AUTH_MARKER } from "../agents/model-auth-markers.js"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentModelPrimaryValue } from "../config/model-input.js"; +import type { ModelDefinitionConfig } from "../config/types.models.js"; +import type { + ProviderAuthMethodNonInteractiveContext, + ProviderDiscoveryContext, +} from "../plugins/types.js"; +import { + configureLmstudioNonInteractive, + discoverLmstudioProvider, + promptAndConfigureLmstudioInteractive, +} from "./lmstudio-setup.js"; +import { mergeConfigPatch } from "./provider-auth-helpers.js"; + +const fetchLmstudioModelsMock = vi.hoisted(() => vi.fn()); +const buildLmstudioProviderMock = vi.hoisted(() => vi.fn()); +const configureSelfHostedNonInteractiveMock = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/lmstudio-models.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchLmstudioModels: (...args: unknown[]) => fetchLmstudioModelsMock(...args), + }; +}); + +vi.mock("../agents/models-config.providers.discovery.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + buildLmstudioProvider: (...args: unknown[]) => buildLmstudioProviderMock(...args), + }; +}); + +vi.mock("./self-hosted-provider-setup.js", async (importOriginal) => { + const actual = await importOriginal(); + 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; +}): ProviderDiscoveryContext { + 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; + customModelId?: string; + resolvedApiKey?: string | null; +}): ProviderAuthMethodNonInteractiveContext & { + runtime: { + error: ReturnType; + exit: ReturnType; + log: ReturnType; + }; + resolveApiKey: ReturnType; + toApiKeyCredential: ReturnType; +} { + 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: "flag" as const, + }, + ); + 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", + customModelId: params?.customModelId, + } as ProviderAuthMethodNonInteractiveContext["opts"], + runtime: { error, exit, log }, + resolveApiKey, + toApiKeyCredential, + }; +} + +describe("lmstudio setup", () => { + beforeEach(() => { + fetchLmstudioModelsMock.mockReset(); + buildLmstudioProviderMock.mockReset(); + configureSelfHostedNonInteractiveMock.mockReset(); + + fetchLmstudioModelsMock.mockResolvedValue({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + }, + ], + }); + buildLmstudioProviderMock.mockResolvedValue({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + configureSelfHostedNonInteractiveMock.mockImplementation(async (args: unknown) => { + const params = args as { + providerId: string; + ctx: ProviderAuthMethodNonInteractiveContext; + }; + const providerId = params.providerId; + const customModelId = + typeof params.ctx.opts.customModelId === "string" + ? params.ctx.opts.customModelId.trim() + : ""; + const modelId = customModelId || "qwen3-8b-instruct"; + const customBaseUrl = + typeof params.ctx.opts.customBaseUrl === "string" + ? params.ctx.opts.customBaseUrl + : undefined; + return { + agents: { + defaults: { + model: { + primary: `${providerId}/${modelId}`, + }, + }, + }, + models: { + providers: { + [providerId]: { + baseUrl: customBaseUrl ?? "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: [createModel(modelId, "Qwen3 8B")], + }, + }, + }, + }; + }); + }); + + 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: 64000, + }, + ], + }); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe( + "lmstudio/qwen3-8b-instruct", + ); + }); + + 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, + }); + const mergedConfig = mergeConfigPatch(buildConfig(), result.configPatch); + + expect(result.configPatch?.models?.mode).toBe("merge"); + expect(mergedConfig).toMatchObject({ + models: { + providers: { + lmstudio: { + 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 replaces stale local auth markers when enabling api-key auth", async () => { + const config = { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + api: "openai-completions", + models: [], + }, + }, + }, + } as OpenClawConfig; + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("lmstudio-test-key"); + + const result = await promptAndConfigureLmstudioInteractive({ + config, + promptText, + }); + const mergedConfig = mergeConfigPatch(config, result.configPatch); + + expect(mergedConfig.models?.providers?.lmstudio).toMatchObject({ + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + }); + }); + + 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("discoverLmstudioProvider short-circuits when explicit models are configured", async () => { + 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, + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + models: explicitModels, + }, + }); + }); + + it("discoverLmstudioProvider keeps api-key auth backed by the default env marker for explicit models", async () => { + const explicitModels = [createModel("qwen3-8b-instruct", "Qwen3 8B")]; + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/api/v1/", + auth: "api-key", + models: explicitModels, + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + models: explicitModels, + }, + }); + }); + + it("discoverLmstudioProvider uses resolved key/headers and non-quiet discovery", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [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(buildLmstudioProviderMock).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 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 uses quiet mode and returns null when unconfigured", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [], + }); + + const result = await discoverLmstudioProvider(buildDiscoveryContext()); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: undefined, + apiKey: undefined, + quiet: true, + }); + expect(result).toBeNull(); + }); + + it("non-interactive setup replaces legacy 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, + }); + }); +}); diff --git a/src/commands/lmstudio-setup.ts b/src/commands/lmstudio-setup.ts new file mode 100644 index 000000000000..f9f1b3a4165c --- /dev/null +++ b/src/commands/lmstudio-setup.ts @@ -0,0 +1,614 @@ +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + LMSTUDIO_MODEL_PLACEHOLDER, + LMSTUDIO_DEFAULT_BASE_URL, + LMSTUDIO_PROVIDER_LABEL, + LMSTUDIO_DEFAULT_MODEL_ID, + LMSTUDIO_PROVIDER_ID as PROVIDER_ID, +} from "../agents/lmstudio-defaults.js"; +import { + discoverLmstudioModels, + fetchLmstudioModels, + mapLmstudioWireEntry, + type LmstudioModelWire, + resolveLmstudioInferenceBase, +} from "../agents/lmstudio-models.js"; +import { + resolveLmstudioProviderHeaders, + resolveLmstudioRuntimeApiKey, +} from "../agents/lmstudio-runtime.js"; +import { CUSTOM_LOCAL_AUTH_MARKER } from "../agents/model-auth-markers.js"; +import { resolveUsableCustomProviderApiKey } from "../agents/model-auth.js"; +import { buildLmstudioProvider } from "../agents/models-config.providers.discovery.js"; +import { projectConfigOntoRuntimeSourceSnapshot, type OpenClawConfig } from "../config/config.js"; +import { createConfigRuntimeEnv } from "../config/env-vars.js"; +import type { ModelDefinitionConfig, ModelProviderConfig } from "../config/types.models.js"; +import { resolveSecretInputRef, type SecretInput } from "../config/types.secrets.js"; +import { buildApiKeyCredential } from "../plugins/provider-auth-helpers.js"; +import type { + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, + ProviderPrepareDynamicModelContext, + ProviderRuntimeModel, +} from "../plugins/types.js"; +import { resolveSecretInputString } from "../secrets/resolve-secret-input-string.js"; +import { normalizeOptionalSecretInput } from "../utils/normalize-secret-input.js"; +import { WizardCancelledError } from "../wizard/prompts.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; +import { ensureApiKeyFromEnvOrPrompt } from "./auth-choice.apply-helpers.js"; +import type { SecretInputMode } from "./onboard-types.js"; +import { configureOpenAICompatibleSelfHostedProviderNonInteractive } from "./self-hosted-provider-setup.js"; + +type ProviderPromptText = (params: { + message: string; + initialValue?: string; + placeholder?: string; + validate?: (value: string | undefined) => string | undefined; +}) => Promise; + +type ProviderPromptNote = (message: string, title?: string) => Promise | void; +type LmstudioDiscoveryResult = Awaited>; + +function resolveLmstudioDiscoveryFailure(params: { + baseUrl: string; + discovery: LmstudioDiscoveryResult; +}): { noteLines: [string, string]; reason: string } | null { + const { baseUrl, discovery } = params; + if (!discovery.reachable) { + return { + noteLines: [ + `LM Studio could not be reached at ${baseUrl}.`, + "Start LM Studio (or run lms server start) and re-run setup.", + ], + reason: "LM Studio not reachable", + }; + } + if (discovery.status !== undefined && discovery.status >= 400) { + return { + noteLines: [ + `LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.`, + "Check the base URL and API key, then re-run setup.", + ], + reason: `LM Studio discovery failed (${discovery.status})`, + }; + } + const hasUsableModel = discovery.models.some( + (model) => model.type === "llm" && Boolean(model.key?.trim()), + ); + if (!hasUsableModel) { + return { + noteLines: [ + `No LM Studio LLM models were found at ${baseUrl}.`, + "Load at least one model in LM Studio (or run lms load), then re-run setup.", + ], + reason: "No LM Studio models found", + }; + } + return null; +} + +function shouldUseLmstudioApiKeyPlaceholder(params: { + hasModels: boolean; + resolvedApiKey: ModelProviderConfig["apiKey"] | undefined; +}): boolean { + return params.hasModels && !params.resolvedApiKey; +} + +function resolveLmstudioProviderAuthMode( + apiKey: ModelProviderConfig["apiKey"] | undefined, +): ModelProviderConfig["auth"] | undefined { + const normalized = normalizeOptionalSecretInput(apiKey); + if (normalized !== undefined) { + return normalized && + normalized !== LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER && + normalized !== CUSTOM_LOCAL_AUTH_MARKER + ? "api-key" + : undefined; + } + return resolveSecretInputRef({ value: apiKey }).ref ? "api-key" : undefined; +} + +function resolvePersistedLmstudioApiKey(params: { + currentApiKey: ModelProviderConfig["apiKey"] | undefined; + explicitAuth: ModelProviderConfig["auth"] | undefined; + fallbackApiKey: ModelProviderConfig["apiKey"] | undefined; + hasModels: boolean; +}): ModelProviderConfig["apiKey"] | undefined { + if (resolveLmstudioProviderAuthMode(params.currentApiKey)) { + return params.currentApiKey; + } + if (params.explicitAuth === "api-key") { + return params.fallbackApiKey; + } + return shouldUseLmstudioApiKeyPlaceholder({ + hasModels: params.hasModels, + resolvedApiKey: params.currentApiKey, + }) + ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER + : undefined; +} + +/** Keeps explicit model entries first and appends unique discovered entries. */ +function mergeDiscoveredModels(params: { + explicitModels?: ModelDefinitionConfig[]; + discoveredModels?: ModelDefinitionConfig[]; +}): ModelDefinitionConfig[] { + const explicitModels = Array.isArray(params.explicitModels) ? params.explicitModels : []; + const discoveredModels = Array.isArray(params.discoveredModels) ? params.discoveredModels : []; + if (explicitModels.length === 0) { + return discoveredModels; + } + if (discoveredModels.length === 0) { + return explicitModels; + } + + const merged = [...explicitModels]; + const seen = new Set(explicitModels.map((model) => model.id.trim()).filter(Boolean)); + for (const model of discoveredModels) { + const id = model.id.trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + merged.push(model); + } + return merged; +} + +/** + * Maps LM Studio discovery payload to provider catalog entries. + * Uses simple display names (no runtime tags) since these entries are persisted to config. + */ +function mapFetchedLmstudioModelsToCatalog(models: LmstudioModelWire[]): ModelDefinitionConfig[] { + return models + .map((entry) => { + const base = mapLmstudioWireEntry(entry); + if (!base) { + return null; + } + return { + id: base.id, + name: base.displayName, + reasoning: base.reasoning, + input: base.input, + cost: base.cost, + contextWindow: base.contextWindow, + maxTokens: base.maxTokens, + }; + }) + .filter((entry): entry is ModelDefinitionConfig => entry !== null); +} + +/** Builds `agents.defaults.models` allowlist entries from discovered model IDs. */ +function mapDiscoveredLmstudioModelsToAllowlistEntries(models: ModelDefinitionConfig[]) { + const entries: Record> = {}; + for (const model of models) { + const id = model.id.trim(); + if (!id) { + continue; + } + entries[`${PROVIDER_ID}/${id}`] = {}; + } + return entries; +} + +function selectDiscoveredLmstudioDefaultModel( + discoveredModels: ModelDefinitionConfig[], +): string | undefined { + const discoveredIds = discoveredModels.map((model) => model.id.trim()).filter(Boolean); + if (discoveredIds.length === 0) { + return undefined; + } + const preferredModelId = discoveredIds.includes(LMSTUDIO_DEFAULT_MODEL_ID) + ? LMSTUDIO_DEFAULT_MODEL_ID + : discoveredIds[0]; + return `${PROVIDER_ID}/${preferredModelId}`; +} + +async function resolveExplicitLmstudioSecretRefDiscoveryApiKey(params: { + config: OpenClawConfig; + env: NodeJS.ProcessEnv; + apiKey: ModelProviderConfig["apiKey"] | undefined; +}): Promise { + const sourceConfig = projectConfigOntoRuntimeSourceSnapshot(params.config); + const { ref } = resolveSecretInputRef({ + value: params.apiKey, + defaults: sourceConfig.secrets?.defaults, + }); + if (!ref) { + return undefined; + } + + try { + return await resolveSecretInputString({ + config: sourceConfig, + env: createConfigRuntimeEnv(sourceConfig, params.env), + value: params.apiKey, + normalize: normalizeOptionalSecretInput, + }); + } catch { + return undefined; + } +} + +/** Interactive LM Studio setup with connectivity and model-availability checks. */ +export async function promptAndConfigureLmstudioInteractive(params: { + config: OpenClawConfig; + prompter?: WizardPrompter; + secretInputMode?: SecretInputMode; + allowSecretRefPrompt?: boolean; + promptText?: ProviderPromptText; + note?: ProviderPromptNote; +}): Promise { + const promptText = params.prompter?.text ?? params.promptText; + if (!promptText) { + throw new Error("LM Studio interactive setup requires a text prompter."); + } + const note = params.prompter?.note ?? params.note; + const baseUrlRaw = await promptText({ + message: `${LMSTUDIO_PROVIDER_LABEL} base URL`, + initialValue: LMSTUDIO_DEFAULT_BASE_URL, + placeholder: LMSTUDIO_DEFAULT_BASE_URL, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + const baseUrl = resolveLmstudioInferenceBase(String(baseUrlRaw ?? "")); + let credentialInput: SecretInput | undefined; + let credentialMode: SecretInputMode | undefined; + const implicitRefMode = params.allowSecretRefPrompt === false && !params.secretInputMode; + const autoRefEnvKey = process.env[LMSTUDIO_DEFAULT_API_KEY_ENV_VAR]?.trim(); + const apiKey = + params.prompter && implicitRefMode && autoRefEnvKey + ? autoRefEnvKey + : params.prompter + ? await ensureApiKeyFromEnvOrPrompt({ + config: params.config, + provider: PROVIDER_ID, + envLabel: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + promptMessage: `${LMSTUDIO_PROVIDER_LABEL} API key`, + normalize: (value) => value.trim(), + validate: (value) => (value.trim() ? undefined : "Required"), + prompter: params.prompter, + secretInputMode: + params.allowSecretRefPrompt === false + ? (params.secretInputMode ?? "plaintext") + : params.secretInputMode, + setCredential: async (apiKeyValue, mode) => { + credentialInput = apiKeyValue; + credentialMode = mode; + }, + }) + : String( + await promptText({ + message: `${LMSTUDIO_PROVIDER_LABEL} API key`, + placeholder: "sk-... (or any non-empty string)", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).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 existingProvider = params.config.models?.providers?.[PROVIDER_ID]; + const resolvedHeaders = await resolveLmstudioProviderHeaders({ + config: params.config, + env: process.env, + headers: existingProvider?.headers, + }); + const discovery = await fetchLmstudioModels({ + baseUrl, + apiKey, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + timeoutMs: 5000, + }); + const discoveryFailure = resolveLmstudioDiscoveryFailure({ baseUrl, discovery }); + if (discoveryFailure) { + await note?.(discoveryFailure.noteLines.join("\n"), "LM Studio"); + throw new WizardCancelledError(discoveryFailure.reason); + } + const discoveredModels = mapFetchedLmstudioModelsToCatalog(discovery.models); + const allowlistEntries = mapDiscoveredLmstudioModelsToAllowlistEntries(discoveredModels); + const defaultModel = selectDiscoveredLmstudioDefaultModel(discoveredModels); + const persistedApiKey = + resolvePersistedLmstudioApiKey({ + currentApiKey: existingProvider?.apiKey, + explicitAuth: "api-key", + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: discoveredModels.length > 0, + }) ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR; + + return { + profiles: [ + { + profileId: `${PROVIDER_ID}:default`, + credential, + }, + ], + configPatch: { + agents: { + defaults: { + models: allowlistEntries, + }, + }, + models: { + // Respect existing global mode; self-hosted provider setup should merge by default. + mode: params.config.models?.mode ?? "merge", + providers: { + [PROVIDER_ID]: { + ...existingProvider, + baseUrl, + api: existingProvider?.api ?? "openai-completions", + auth: "api-key", + apiKey: persistedApiKey, + models: discoveredModels, + }, + }, + }, + }, + defaultModel, + }; +} + +/** Non-interactive setup path backed by the shared self-hosted helper. */ +export async function configureLmstudioNonInteractive( + ctx: ProviderAuthMethodNonInteractiveContext, +): Promise { + const customBaseUrl = normalizeOptionalSecretInput(ctx.opts.customBaseUrl); + const baseUrl = resolveLmstudioInferenceBase( + customBaseUrl || LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + ); + 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: LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + defaultApiKeyEnvVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: LMSTUDIO_MODEL_PLACEHOLDER, + }); + const modelId = normalizeOptionalSecretInput(normalizedCtx.opts.customModelId); + if (!modelId) { + const configured = await configureShared(normalizedCtx); + if (!configured) { + return null; + } + const configuredProvider = configured.models?.providers?.[PROVIDER_ID]; + if (!configuredProvider) { + return configured; + } + return { + ...configured, + models: { + ...configured.models, + providers: { + ...configured.models?.providers, + [PROVIDER_ID]: { + ...configuredProvider, + auth: "api-key", + }, + }, + }, + }; + } + + const resolved = await normalizedCtx.resolveApiKey({ + provider: PROVIDER_ID, + flagValue: normalizeOptionalSecretInput(normalizedCtx.opts.customApiKey), + flagName: "--custom-api-key", + envVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + envVarName: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + }); + if (!resolved) { + return null; + } + + const existingProvider = normalizedCtx.config.models?.providers?.[PROVIDER_ID]; + const resolvedHeaders = await resolveLmstudioProviderHeaders({ + config: normalizedCtx.config, + env: process.env, + headers: existingProvider?.headers, + }); + const discovery = await fetchLmstudioModels({ + baseUrl, + apiKey: resolved.key, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + timeoutMs: 5000, + }); + const discoveryFailure = resolveLmstudioDiscoveryFailure({ baseUrl, discovery }); + if (discoveryFailure) { + normalizedCtx.runtime.error(discoveryFailure.noteLines.join("\n")); + normalizedCtx.runtime.exit(1); + return null; + } + const discoveredModels = mapFetchedLmstudioModelsToCatalog(discovery.models); + if (!discoveredModels.some((model) => model.id === modelId)) { + normalizedCtx.runtime.error( + [ + `LM Studio model ${modelId} was not found at ${baseUrl}.`, + `Available models: ${discoveredModels.map((model) => model.id).join(", ")}`, + ].join("\n"), + ); + normalizedCtx.runtime.exit(1); + return null; + } + + // Delegate to the shared helper even when modelId is set so that onboarding + // 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, + resolveApiKey: async () => resolved, + }); + if (!configured) { + return null; + } + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: existingProvider?.apiKey, + explicitAuth: "api-key", + fallbackApiKey: + configured.models?.providers?.[PROVIDER_ID]?.apiKey ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: discoveredModels.length > 0, + }); + + return { + ...configured, + models: { + ...configured.models, + providers: { + ...configured.models?.providers, + // Preserve compatible auth config while refreshing LM Studio transport + model catalog. + [PROVIDER_ID]: { + ...existingProvider, + ...configured.models?.providers?.[PROVIDER_ID], + ...(persistedApiKey !== undefined ? { apiKey: persistedApiKey } : {}), + ...(existingProvider?.headers ? { headers: existingProvider.headers } : {}), + baseUrl, + api: configured.models?.providers?.[PROVIDER_ID]?.api ?? "openai-completions", + auth: "api-key", + models: discoveredModels, + }, + }, + }, + }; +} + +/** Discovers provider settings, merging explicit config with live model discovery. */ +export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): Promise<{ + provider: ModelProviderConfig; +} | null> { + const explicit = ctx.config.models?.providers?.[PROVIDER_ID]; + const explicitAuth = explicit?.auth; + const explicitWithoutHeaders = explicit + ? (() => { + const { headers: _headers, auth: _auth, ...rest } = explicit; + return rest; + })() + : undefined; + const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0; + const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID); + const explicitDiscoveryApiKey = resolveUsableCustomProviderApiKey({ + cfg: ctx.config, + provider: PROVIDER_ID, + env: ctx.env, + })?.apiKey; + const explicitSecretRefDiscoveryApiKey = await resolveExplicitLmstudioSecretRefDiscoveryApiKey({ + config: ctx.config, + env: ctx.env, + apiKey: explicit?.apiKey, + }); + const resolvedHeaders = await resolveLmstudioProviderHeaders({ + config: ctx.config, + env: ctx.env, + headers: explicit?.headers, + }); + // CLI/runtime-resolved key takes precedence over static provider config key. + const resolvedApiKey = apiKey ?? explicit?.apiKey; + if (hasExplicitModels && explicitWithoutHeaders) { + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: resolvedApiKey, + explicitAuth, + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: hasExplicitModels, + }); + const persistedAuth = resolveLmstudioProviderAuthMode(persistedApiKey); + return { + provider: { + ...explicitWithoutHeaders, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + baseUrl: resolveLmstudioInferenceBase(explicitWithoutHeaders.baseUrl), + // Keep explicit API unless absent, then fall back to provider default. + api: explicitWithoutHeaders.api ?? "openai-completions", + ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), + ...(persistedAuth ? { auth: persistedAuth } : {}), + models: explicitWithoutHeaders.models, + }, + }; + } + const provider = await buildLmstudioProvider({ + baseUrl: explicit?.baseUrl, + // Prefer dedicated discovery key, then explicit env-backed custom-provider key, + // then explicit SecretRef-backed provider keys. + apiKey: discoveryApiKey ?? explicitDiscoveryApiKey ?? explicitSecretRefDiscoveryApiKey, + headers: resolvedHeaders, + quiet: !apiKey && !explicit && !explicitDiscoveryApiKey && !explicitSecretRefDiscoveryApiKey, + }); + const models = mergeDiscoveredModels({ + explicitModels: explicit?.models, + discoveredModels: provider.models, + }); + if (models.length === 0 && !apiKey && !explicit?.apiKey) { + return null; + } + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: resolvedApiKey, + explicitAuth, + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: models.length > 0, + }); + const persistedAuth = resolveLmstudioProviderAuthMode(persistedApiKey); + return { + provider: { + ...provider, + ...explicitWithoutHeaders, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + baseUrl: resolveLmstudioInferenceBase(explicit?.baseUrl ?? provider.baseUrl), + ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), + ...(persistedAuth ? { auth: persistedAuth } : {}), + models, + }, + }; +} + +export async function prepareLmstudioDynamicModels( + ctx: ProviderPrepareDynamicModelContext, +): Promise { + const baseUrl = resolveLmstudioInferenceBase(ctx.providerConfig?.baseUrl); + const apiKey = await resolveLmstudioRuntimeApiKey({ + config: ctx.config, + agentDir: ctx.agentDir, + allowMissingAuth: true, + env: process.env, + }); + const headers = await resolveLmstudioProviderHeaders({ + config: ctx.config, + env: process.env, + headers: ctx.providerConfig?.headers, + }); + const discoveredModels = await discoverLmstudioModels({ + baseUrl, + apiKey: apiKey ?? "", + headers, + quiet: true, + }); + return discoveredModels.map((model) => ({ + ...model, + provider: PROVIDER_ID, + api: ctx.providerConfig?.api ?? "openai-completions", + baseUrl, + })); +} diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index f5140c38e4eb..6b77e930394b 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -340,7 +340,11 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.auth?.profiles?.["xai:default"]?.provider).toBe("xai"); expect(cfg.auth?.profiles?.["xai:default"]?.mode).toBe("api_key"); expect(cfg.agents?.defaults?.model?.primary).toBe("xai/grok-4"); - await expectApiKeyProfile({ profileId: "xai:default", provider: "xai", key: "xai-test-key" }); + await expectApiKeyProfile({ + profileId: "xai:default", + provider: "xai", + key: "xai-test-key", + }); }); }); @@ -617,6 +621,89 @@ describe("onboard (non-interactive): provider auth", () => { }); }); + it("configures LM Studio via the provider plugin in non-interactive mode", async () => { + const fetchMock = vi.fn(async (input: string | URL | RequestInfo | Request) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : "url" in input + ? String(input.url) + : String(input); + if (!url.endsWith("/api/v1/models")) { + throw new Error(`Unexpected fetch URL: ${url}`); + } + return new Response( + JSON.stringify({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + display_name: "Qwen3 8B", + }, + { + type: "llm", + key: "phi-4", + }, + ], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + }) as typeof fetch; + vi.stubGlobal("fetch", fetchMock); + try { + await withOnboardEnv("openclaw-onboard-lmstudio-non-interactive-", async (env) => { + const cfg = await runOnboardingAndReadConfig(env, { + authChoice: "lmstudio", + customBaseUrl: "http://localhost:1234/v1", + customApiKey: "lmstudio-test-key", // pragma: allowlist secret + customModelId: "qwen3-8b-instruct", + }); + + expect(cfg.auth?.profiles?.["lmstudio:default"]?.provider).toBe("lmstudio"); + expect(cfg.auth?.profiles?.["lmstudio:default"]?.mode).toBe("api_key"); + expect(cfg.models?.providers?.lmstudio).toEqual({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: [ + expect.objectContaining({ + id: "qwen3-8b-instruct", + }), + expect.objectContaining({ + id: "phi-4", + }), + ], + }); + expect(cfg.agents?.defaults?.model?.primary).toBe("lmstudio/qwen3-8b-instruct"); + await expectApiKeyProfile({ + profileId: "lmstudio:default", + provider: "lmstudio", + key: "lmstudio-test-key", + }); + }); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("fails LM Studio non-interactive setup when API key is missing", async () => { + await withOnboardEnv("openclaw-onboard-lmstudio-no-key-non-interactive-", async (env) => { + await expect( + runNonInteractiveSetupWithDefaults(env.runtime, { + authChoice: "lmstudio", + customBaseUrl: "http://localhost:1234/v1", + customModelId: "qwen3-8b-instruct", + }), + ).rejects.toThrow("Missing --custom-api-key (or LM_API_TOKEN in env"); + }); + }); + it("stores LiteLLM API key and sets default model", async () => { await withOnboardEnv("openclaw-onboard-litellm-", async (env) => { const cfg = await runOnboardingAndReadConfig(env, { diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index a13854d20950..a2b0138e4f21 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -1900,6 +1900,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { type: "string", const: "mistral", }, + { + type: "string", + const: "lmstudio", + }, { type: "string", const: "ollama", @@ -2039,6 +2043,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { type: "string", const: "mistral", }, + { + type: "string", + const: "lmstudio", + }, { type: "string", const: "ollama", @@ -3517,6 +3525,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { type: "string", const: "mistral", }, + { + type: "string", + const: "lmstudio", + }, { type: "string", const: "ollama", @@ -3656,6 +3668,10 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { type: "string", const: "mistral", }, + { + type: "string", + const: "lmstudio", + }, { type: "string", const: "ollama", @@ -13386,7 +13402,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { }, "agents.defaults.memorySearch.provider": { label: "Memory Search Provider", - help: 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', + help: 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "lmstudio", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', tags: ["advanced"], }, "agents.defaults.memorySearch.remote.baseUrl": { @@ -13442,7 +13458,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA = { }, "agents.defaults.memorySearch.fallback": { label: "Memory Search Fallback", - help: 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', + help: 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "lmstudio", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', tags: ["reliability"], }, "agents.defaults.memorySearch.local.modelPath": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 78ba36c5925a..77b9ac3c8516 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -799,7 +799,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.experimental.sessionMemory": "Indexes session transcripts into memory search so responses can reference prior chat turns. Keep this off unless transcript recall is needed, because indexing cost and storage usage both increase.", "agents.defaults.memorySearch.provider": - 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', + 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "lmstudio", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', "agents.defaults.memorySearch.model": "Embedding model override used by the selected memory provider when a non-default model is required. Set this only when you need explicit recall quality/cost tuning beyond provider defaults.", "agents.defaults.memorySearch.outputDimensionality": @@ -823,7 +823,7 @@ export const FIELD_HELP: Record = { "agents.defaults.memorySearch.local.modelPath": "Specifies the local embedding model source for local memory search, such as a GGUF file path or `hf:` URI. Use this only when provider is `local`, and verify model compatibility before large index rebuilds.", "agents.defaults.memorySearch.fallback": - 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', + 'Backup provider used when primary embeddings fail: "openai", "gemini", "voyage", "mistral", "lmstudio", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', "agents.defaults.memorySearch.store.path": "Sets where the SQLite memory index is stored on disk for each agent. Keep the default `~/.openclaw/memory/{agentId}.sqlite` unless you need custom storage placement or backup policy alignment.", "agents.defaults.memorySearch.store.vector.enabled": diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts index fb95c3ac64f5..26c2ac0f91ad 100644 --- a/src/config/types.tools.ts +++ b/src/config/types.tools.ts @@ -339,7 +339,7 @@ export type MemorySearchConfig = { sessionMemory?: boolean; }; /** Embedding provider mode. */ - provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama"; + provider?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "lmstudio" | "ollama"; remote?: { baseUrl?: string; apiKey?: SecretInput; @@ -358,7 +358,7 @@ export type MemorySearchConfig = { }; }; /** Fallback behavior when embeddings fail. */ - fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "ollama" | "none"; + fallback?: "openai" | "gemini" | "local" | "voyage" | "mistral" | "lmstudio" | "ollama" | "none"; /** Embedding model id (remote) or alias (local). */ model?: string; /** diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 722c5ec9bafd..c8f3686d7fe9 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -612,6 +612,7 @@ export const MemorySearchSchema = z z.literal("gemini"), z.literal("voyage"), z.literal("mistral"), + z.literal("lmstudio"), z.literal("ollama"), ]) .optional(), @@ -640,6 +641,7 @@ export const MemorySearchSchema = z z.literal("local"), z.literal("voyage"), z.literal("mistral"), + z.literal("lmstudio"), z.literal("ollama"), z.literal("none"), ]) diff --git a/src/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts new file mode 100644 index 000000000000..e93484582775 --- /dev/null +++ b/src/memory/embeddings-lmstudio.test.ts @@ -0,0 +1,297 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; + +const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn()); +const resolveLmstudioRuntimeApiKeyMock = vi.hoisted(() => vi.fn()); + +vi.mock("../agents/lmstudio-models.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureLmstudioModelLoaded: (...args: unknown[]) => ensureLmstudioModelLoadedMock(...args), + }; +}); + +vi.mock("../agents/lmstudio-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLmstudioRuntimeApiKey: (...args: unknown[]) => resolveLmstudioRuntimeApiKeyMock(...args), + }; +}); + +let createLmstudioEmbeddingProvider: typeof import("./embeddings-lmstudio.js").createLmstudioEmbeddingProvider; + +describe("embeddings-lmstudio", () => { + const originalFetch = globalThis.fetch; + const jsonResponse = (embedding: number[]) => + new Response( + JSON.stringify({ + data: [{ embedding }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + + function mockEmbeddingFetch(embedding: number[]) { + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue(jsonResponse(embedding)); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + + beforeEach(async () => { + vi.resetModules(); + ({ createLmstudioEmbeddingProvider } = await import("./embeddings-lmstudio.js")); + ensureLmstudioModelLoadedMock.mockReset(); + resolveLmstudioRuntimeApiKeyMock.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("embeds against inference base and warms model with resolved key", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + const fetchMock = mockEmbeddingFetch([0.1, 0.2]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/api/v1/", + headers: { "X-Provider": "provider" }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "lmstudio/text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer profile-lmstudio-key", + "X-Provider": "provider", + }), + }), + ); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-lmstudio-key", + headers: { + "X-Provider": "provider", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + }); + + it("ignores memorySearch remote overrides and uses lmstudio provider config", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + headers: { + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "", + fallback: "none", + remote: { + baseUrl: "http://localhost:9999", + apiKey: "remote-lmstudio-key", + headers: { + "X-Provider": "remote", + "X-Remote-Only": "from-remote", + }, + }, + }); + + await provider.embedBatch(["one", "two"]); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer profile-key", + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }), + }), + ); + const callHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(callHeaders["X-Remote-Only"]).toBeUndefined(); + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalled(); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-key", + headers: { + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + }); + + it("allows keyless local LM Studio", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue(undefined); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + const callHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(callHeaders.Authorization).toBeUndefined(); + expect(callHeaders["Content-Type"]).toBe("application/json"); + }); + + it("fails fast when explicit api-key auth cannot be resolved", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockRejectedValue( + new Error('No API key found for provider "lmstudio".'), + ); + + await expect( + createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + auth: "api-key", + baseUrl: "http://localhost:1234/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }), + ).rejects.toThrow('No API key found for provider "lmstudio".'); + + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ allowMissingAuth: false }), + ); + }); + + it("continues embedding when warmup fails", async () => { + ensureLmstudioModelLoadedMock.mockRejectedValue(new Error("warmup failed")); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + method: "POST", + }), + ); + }); + + it("awaits warmup before returning provider", async () => { + let resolveWarmup: (() => void) | undefined; + ensureLmstudioModelLoadedMock.mockImplementation( + () => + new Promise((resolve) => { + resolveWarmup = resolve; + }), + ); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + let settled = false; + const providerPromise = createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }).then(() => { + settled = true; + }); + + await vi.waitFor(() => { + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + expect(resolveWarmup).toBeTypeOf("function"); + }); + expect(settled).toBe(false); + + resolveWarmup?.(); + await providerPromise; + expect(settled).toBe(true); + }); +}); diff --git a/src/memory/embeddings-lmstudio.ts b/src/memory/embeddings-lmstudio.ts new file mode 100644 index 000000000000..feb9a26b8ece --- /dev/null +++ b/src/memory/embeddings-lmstudio.ts @@ -0,0 +1,114 @@ +import { + LMSTUDIO_DEFAULT_EMBEDDING_MODEL, + LMSTUDIO_PROVIDER_ID, +} from "../agents/lmstudio-defaults.js"; +import { + ensureLmstudioModelLoaded, + resolveLmstudioInferenceBase, +} from "../agents/lmstudio-models.js"; +import { + buildLmstudioAuthHeaders, + resolveLmstudioProviderHeaders, + resolveLmstudioRuntimeApiKey, +} from "../agents/lmstudio-runtime.js"; +import { formatErrorMessage } from "../infra/errors.js"; +import type { SsrFPolicy } from "../infra/net/ssrf.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js"; +import { createRemoteEmbeddingProvider } from "./embeddings-remote-provider.js"; +import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; +import { buildRemoteBaseUrlPolicy } from "./remote-http.js"; + +const log = createSubsystemLogger("memory/embeddings"); + +export type LmstudioEmbeddingClient = { + baseUrl: string; + headers: Record; + ssrfPolicy?: SsrFPolicy; + model: string; +}; +export const DEFAULT_LMSTUDIO_EMBEDDING_MODEL = LMSTUDIO_DEFAULT_EMBEDDING_MODEL; + +/** Normalizes LM Studio embedding model refs and accepts `lmstudio/` prefix. */ +function normalizeLmstudioModel(model: string): string { + return normalizeEmbeddingModelWithPrefixes({ + model, + defaultModel: DEFAULT_LMSTUDIO_EMBEDDING_MODEL, + prefixes: ["lmstudio/"], + }); +} + +/** Resolves API key from runtime/provider auth config. + * Returns undefined when no key is configured, and throws on auth lookup failures. */ +async function resolveLmstudioApiKey( + options: EmbeddingProviderOptions, +): Promise { + const authMode = options.config.models?.providers?.lmstudio?.auth; + return await resolveLmstudioRuntimeApiKey({ + config: options.config, + agentDir: options.agentDir, + // Explicit api-key auth should fail fast when credentials are missing. + allowMissingAuth: authMode !== "api-key", + }); +} + +/** Creates the LM Studio embedding provider client and preloads the target model before return. */ +export async function createLmstudioEmbeddingProvider( + options: EmbeddingProviderOptions, +): Promise<{ provider: EmbeddingProvider; client: LmstudioEmbeddingClient }> { + const providerConfig = options.config.models?.providers?.lmstudio; + const providerBaseUrl = providerConfig?.baseUrl?.trim(); + // LM Studio should resolve from models.providers.lmstudio only. + // Do not honor memorySearch.remote overrides here, or a fallback from another + // remote provider can keep using that provider's endpoint or credentials. + const configuredBaseUrl = + providerBaseUrl && providerBaseUrl.length > 0 ? providerBaseUrl : undefined; + const baseUrl = resolveLmstudioInferenceBase(configuredBaseUrl); + const model = normalizeLmstudioModel(options.model); + const apiKey = await resolveLmstudioApiKey(options); + const providerHeaders = await resolveLmstudioProviderHeaders({ + config: options.config, + env: process.env, + headers: providerConfig?.headers, + }); + const headerOverrides = Object.assign({}, providerHeaders); + const headers = + buildLmstudioAuthHeaders({ + apiKey, + json: true, + headers: headerOverrides, + }) ?? {}; + const ssrfPolicy = buildRemoteBaseUrlPolicy(baseUrl); + const client: LmstudioEmbeddingClient = { + baseUrl, + model, + headers, + ssrfPolicy, + }; + + try { + await ensureLmstudioModelLoaded({ + baseUrl, + apiKey, + headers: headerOverrides, + ssrfPolicy, + modelKey: model, + timeoutMs: 120_000, + }); + } catch (error) { + log.warn("lmstudio embeddings warmup failed; continuing without preload", { + baseUrl, + model, + error: formatErrorMessage(error), + }); + } + + return { + provider: createRemoteEmbeddingProvider({ + id: LMSTUDIO_PROVIDER_ID, + client, + errorPrefix: "lmstudio embeddings failed", + }), + client, + }; +} diff --git a/src/memory/embeddings.ts b/src/memory/embeddings.ts index 3e38ef7f210b..a94e6905a4e4 100644 --- a/src/memory/embeddings.ts +++ b/src/memory/embeddings.ts @@ -11,6 +11,10 @@ import { type GeminiEmbeddingClient, type GeminiTaskType, } from "./embeddings-gemini.js"; +import { + createLmstudioEmbeddingProvider, + type LmstudioEmbeddingClient, +} from "./embeddings-lmstudio.js"; import { createMistralEmbeddingProvider, type MistralEmbeddingClient, @@ -21,6 +25,7 @@ import { createVoyageEmbeddingProvider, type VoyageEmbeddingClient } from "./emb import { importNodeLlamaCpp } from "./node-llama.js"; export type { GeminiEmbeddingClient } from "./embeddings-gemini.js"; +export type { LmstudioEmbeddingClient } from "./embeddings-lmstudio.js"; export type { MistralEmbeddingClient } from "./embeddings-mistral.js"; export type { OpenAiEmbeddingClient } from "./embeddings-openai.js"; export type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; @@ -35,13 +40,20 @@ export type EmbeddingProvider = { embedBatchInputs?: (inputs: EmbeddingInput[]) => Promise; }; -export type EmbeddingProviderId = "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; +export type EmbeddingProviderId = + | "openai" + | "local" + | "gemini" + | "voyage" + | "mistral" + | "lmstudio" + | "ollama"; export type EmbeddingProviderRequest = EmbeddingProviderId | "auto"; export type EmbeddingProviderFallback = EmbeddingProviderId | "none"; // Remote providers considered for auto-selection when provider === "auto". -// Ollama is intentionally excluded here so that "auto" mode does not -// implicitly assume a local Ollama instance is available. +// LM Studio and Ollama are intentionally excluded here so that "auto" mode does not +// implicitly assume either instance is available. const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const; export type EmbeddingProviderResult = { @@ -55,6 +67,7 @@ export type EmbeddingProviderResult = { voyage?: VoyageEmbeddingClient; mistral?: MistralEmbeddingClient; ollama?: OllamaEmbeddingClient; + lmstudio?: LmstudioEmbeddingClient; }; export type EmbeddingProviderOptions = { @@ -176,6 +189,10 @@ export async function createEmbeddingProvider( const provider = await createLocalEmbeddingProvider(options); return { provider }; } + if (id === "lmstudio") { + const { provider, client } = await createLmstudioEmbeddingProvider(options); + return { provider, lmstudio: client }; + } if (id === "ollama") { const { provider, client } = await createOllamaEmbeddingProvider(options); return { provider, ollama: client }; @@ -268,7 +285,9 @@ export async function createEmbeddingProvider( }; } // Non-auth errors are still fatal - const wrapped = new Error(combinedReason) as Error & { cause?: unknown }; + const wrapped = new Error(combinedReason) as Error & { + cause?: unknown; + }; wrapped.cause = fallbackErr; throw wrapped; } diff --git a/src/memory/manager-embedding-ops.ts b/src/memory/manager-embedding-ops.ts index fe9b27acd329..71f4084f9d61 100644 --- a/src/memory/manager-embedding-ops.ts +++ b/src/memory/manager-embedding-ops.ts @@ -262,6 +262,20 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps { }), ); } + if (this.provider.id === "lmstudio" && this.lmstudio) { + const entries = Object.entries(this.lmstudio.headers) + .filter(([key]) => key.toLowerCase() !== "authorization") + .toSorted(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => [key, value]); + return hashText( + JSON.stringify({ + provider: "lmstudio", + baseUrl: this.lmstudio.baseUrl, + model: this.lmstudio.model, + headers: entries, + }), + ); + } return hashText(JSON.stringify({ provider: this.provider.id, model: this.provider.model })); } diff --git a/src/memory/manager-sync-ops.ts b/src/memory/manager-sync-ops.ts index 0822dd419786..4e4d205c6b20 100644 --- a/src/memory/manager-sync-ops.ts +++ b/src/memory/manager-sync-ops.ts @@ -12,6 +12,7 @@ import { createSubsystemLogger } from "../logging/subsystem.js"; import { onSessionTranscriptUpdate } from "../sessions/transcript-events.js"; import { resolveUserPath } from "../utils.js"; import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js"; +import { DEFAULT_LMSTUDIO_EMBEDDING_MODEL } from "./embeddings-lmstudio.js"; import { DEFAULT_MISTRAL_EMBEDDING_MODEL } from "./embeddings-mistral.js"; import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import { DEFAULT_OPENAI_EMBEDDING_MODEL } from "./embeddings-openai.js"; @@ -20,6 +21,7 @@ import { createEmbeddingProvider, type EmbeddingProvider, type GeminiEmbeddingClient, + type LmstudioEmbeddingClient, type MistralEmbeddingClient, type OllamaEmbeddingClient, type OpenAiEmbeddingClient, @@ -106,12 +108,20 @@ export abstract class MemoryManagerSyncOps { protected abstract readonly workspaceDir: string; protected abstract readonly settings: ResolvedMemorySearchConfig; protected provider: EmbeddingProvider | null = null; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; + protected fallbackFrom?: + | "openai" + | "local" + | "gemini" + | "voyage" + | "mistral" + | "lmstudio" + | "ollama"; protected openAi?: OpenAiEmbeddingClient; protected gemini?: GeminiEmbeddingClient; protected voyage?: VoyageEmbeddingClient; protected mistral?: MistralEmbeddingClient; protected ollama?: OllamaEmbeddingClient; + protected lmstudio?: LmstudioEmbeddingClient; protected abstract batch: { enabled: boolean; wait: boolean; @@ -1105,6 +1115,7 @@ export abstract class MemoryManagerSyncOps { | "local" | "voyage" | "mistral" + | "lmstudio" | "ollama"; const fallbackModel = @@ -1116,9 +1127,11 @@ export abstract class MemoryManagerSyncOps { ? DEFAULT_VOYAGE_EMBEDDING_MODEL : fallback === "mistral" ? DEFAULT_MISTRAL_EMBEDDING_MODEL - : fallback === "ollama" - ? DEFAULT_OLLAMA_EMBEDDING_MODEL - : this.settings.model; + : fallback === "lmstudio" + ? DEFAULT_LMSTUDIO_EMBEDDING_MODEL + : fallback === "ollama" + ? DEFAULT_OLLAMA_EMBEDDING_MODEL + : this.settings.model; const fallbackResult = await createEmbeddingProvider({ config: this.cfg, @@ -1139,6 +1152,7 @@ export abstract class MemoryManagerSyncOps { this.voyage = fallbackResult.voyage; this.mistral = fallbackResult.mistral; this.ollama = fallbackResult.ollama; + this.lmstudio = fallbackResult.lmstudio; this.providerKey = this.computeProviderKey(); this.batch = this.resolveBatchConfig(); log.warn(`memory embeddings: switched to fallback provider (${fallback})`, { reason }); diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index 828c857d3dcd..ca6d3c1e43ba 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -3,10 +3,12 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; +import { DEFAULT_LMSTUDIO_EMBEDDING_MODEL } from "./embeddings-lmstudio.js"; import { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./embeddings-ollama.js"; import type { EmbeddingProvider, EmbeddingProviderResult, + LmstudioEmbeddingClient, MistralEmbeddingClient, OllamaEmbeddingClient, OpenAiEmbeddingClient, @@ -22,7 +24,10 @@ vi.mock("./embeddings.js", () => ({ })); vi.mock("./sqlite-vec.js", () => ({ - loadSqliteVecExtension: async () => ({ ok: false, error: "sqlite-vec disabled in tests" }), + loadSqliteVecExtension: async () => ({ + ok: false, + error: "sqlite-vec disabled in tests", + }), })); type MemoryIndexModule = typeof import("./index.js"); @@ -42,8 +47,8 @@ function createProvider(id: string): EmbeddingProvider { function buildConfig(params: { workspaceDir: string; indexPath: string; - provider: "openai" | "mistral"; - fallback?: "none" | "mistral" | "ollama"; + provider: "openai" | "mistral" | "lmstudio"; + fallback?: "none" | "mistral" | "ollama" | "lmstudio"; }): OpenClawConfig { return { agents: { @@ -138,7 +143,12 @@ describe("memory manager mistral provider wiring", () => { mistral: mistralClient, } as EmbeddingProviderResult); - const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "mistral" }); + const cfg = buildConfig({ + workspaceDir, + indexPath, + provider: "openai", + fallback: "mistral", + }); const result = await getMemorySearchManager({ cfg, agentId: "main" }); if (!result.manager) { throw new Error(`manager missing: ${result.error ?? "no error provided"}`); @@ -179,7 +189,12 @@ describe("memory manager mistral provider wiring", () => { ollama: ollamaClient, } as EmbeddingProviderResult); - const cfg = buildConfig({ workspaceDir, indexPath, provider: "openai", fallback: "ollama" }); + const cfg = buildConfig({ + workspaceDir, + indexPath, + provider: "openai", + fallback: "ollama", + }); const result = await getMemorySearchManager({ cfg, agentId: "main" }); if (!result.manager) { throw new Error(`manager missing: ${result.error ?? "no error provided"}`); @@ -202,4 +217,56 @@ describe("memory manager mistral provider wiring", () => { expect(fallbackCall?.provider).toBe("ollama"); expect(fallbackCall?.model).toBe(DEFAULT_OLLAMA_EMBEDDING_MODEL); }); + + it("uses default lmstudio model when activating lmstudio fallback", async () => { + const openAiClient: OpenAiEmbeddingClient = { + baseUrl: "https://api.openai.com/v1", + headers: { authorization: "Bearer openai-key" }, + model: "text-embedding-3-small", + }; + const lmstudioClient: LmstudioEmbeddingClient = { + baseUrl: "http://localhost:1234/v1", + headers: { authorization: "Bearer lmstudio-key" }, + model: DEFAULT_LMSTUDIO_EMBEDDING_MODEL, + }; + createEmbeddingProviderMock.mockResolvedValueOnce({ + requestedProvider: "openai", + provider: createProvider("openai"), + openAi: openAiClient, + } as EmbeddingProviderResult); + createEmbeddingProviderMock.mockResolvedValueOnce({ + requestedProvider: "lmstudio", + provider: createProvider("lmstudio"), + lmstudio: lmstudioClient, + } as EmbeddingProviderResult); + + const cfg = buildConfig({ + workspaceDir, + indexPath, + provider: "openai", + fallback: "lmstudio", + }); + const result = await getMemorySearchManager({ cfg, agentId: "main" }); + if (!result.manager) { + throw new Error(`manager missing: ${result.error ?? "no error provided"}`); + } + manager = result.manager as unknown as MemoryIndexManager; + const internal = manager as unknown as { + activateFallbackProvider: (reason: string) => Promise; + lmstudio?: LmstudioEmbeddingClient; + }; + + const activated = await internal.activateFallbackProvider("forced lmstudio fallback"); + expect(activated).toBe(true); + expect(internal.lmstudio).toBe(lmstudioClient); + + const fallbackCall = createEmbeddingProviderMock.mock.calls[1]?.[0] as + | { + provider?: string; + model?: string; + } + | undefined; + expect(fallbackCall?.provider).toBe("lmstudio"); + expect(fallbackCall?.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); + }); }); diff --git a/src/memory/manager.ts b/src/memory/manager.ts index 26bdb64eaebe..802eabe36f11 100644 --- a/src/memory/manager.ts +++ b/src/memory/manager.ts @@ -12,6 +12,7 @@ import { type EmbeddingProviderRequest, type EmbeddingProviderResult, type GeminiEmbeddingClient, + type LmstudioEmbeddingClient, type MistralEmbeddingClient, type OllamaEmbeddingClient, type OpenAiEmbeddingClient, @@ -84,7 +85,14 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem private readonly requestedProvider: EmbeddingProviderRequest; private providerInitPromise: Promise | null = null; private providerInitialized = false; - protected fallbackFrom?: "openai" | "local" | "gemini" | "voyage" | "mistral" | "ollama"; + protected fallbackFrom?: + | "openai" + | "local" + | "gemini" + | "voyage" + | "mistral" + | "lmstudio" + | "ollama"; protected fallbackReason?: string; private providerUnavailableReason?: string; protected openAi?: OpenAiEmbeddingClient; @@ -92,6 +100,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem protected voyage?: VoyageEmbeddingClient; protected mistral?: MistralEmbeddingClient; protected ollama?: OllamaEmbeddingClient; + protected lmstudio?: LmstudioEmbeddingClient; protected batch: { enabled: boolean; wait: boolean; @@ -284,6 +293,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem this.voyage = providerResult.voyage; this.mistral = providerResult.mistral; this.ollama = providerResult.ollama; + this.lmstudio = providerResult.lmstudio; this.providerInitialized = true; } diff --git a/src/plugin-sdk/lmstudio-defaults.ts b/src/plugin-sdk/lmstudio-defaults.ts new file mode 100644 index 000000000000..06cb736255bf --- /dev/null +++ b/src/plugin-sdk/lmstudio-defaults.ts @@ -0,0 +1,6 @@ +export { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + LMSTUDIO_PROVIDER_ID, + LMSTUDIO_PROVIDER_LABEL, +} from "../agents/lmstudio-defaults.js"; diff --git a/src/plugin-sdk/lmstudio-setup.ts b/src/plugin-sdk/lmstudio-setup.ts new file mode 100644 index 000000000000..bf1fc8352744 --- /dev/null +++ b/src/plugin-sdk/lmstudio-setup.ts @@ -0,0 +1,21 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, + ProviderPrepareDynamicModelContext, + ProviderRuntimeModel, +} from "../plugins/types.js"; + +export { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_PROVIDER_LABEL, +} from "../agents/lmstudio-defaults.js"; + +export { + configureLmstudioNonInteractive, + discoverLmstudioProvider, + prepareLmstudioDynamicModels, + promptAndConfigureLmstudioInteractive, +} from "../commands/lmstudio-setup.js"; diff --git a/src/plugin-sdk/provider-setup.ts b/src/plugin-sdk/provider-setup.ts index a5d48c22c4b0..3afa650ca495 100644 --- a/src/plugin-sdk/provider-setup.ts +++ b/src/plugin-sdk/provider-setup.ts @@ -32,6 +32,7 @@ export { promptAndConfigureVllm, } from "../plugins/provider-vllm-setup.js"; export { + buildLmstudioProvider, buildOllamaProvider, buildSglangProvider, buildVllmProvider, diff --git a/src/plugin-sdk/self-hosted-provider-setup.ts b/src/plugin-sdk/self-hosted-provider-setup.ts index 871aa4fb5664..9ff57104fb6b 100644 --- a/src/plugin-sdk/self-hosted-provider-setup.ts +++ b/src/plugin-sdk/self-hosted-provider-setup.ts @@ -19,6 +19,7 @@ export { } from "../plugins/provider-self-hosted-setup.js"; export { + buildLmstudioProvider, buildSglangProvider, buildVllmProvider, } from "../agents/models-config.providers.discovery.js"; diff --git a/src/plugins/bundled-plugin-metadata.generated.ts b/src/plugins/bundled-plugin-metadata.generated.ts index cdd00540c030..5a6eeda0d561 100644 --- a/src/plugins/bundled-plugin-metadata.generated.ts +++ b/src/plugins/bundled-plugin-metadata.generated.ts @@ -1520,6 +1520,44 @@ export const GENERATED_BUNDLED_PLUGIN_METADATA = [ description: "Generic JSON-only LLM tool for structured tasks callable from workflows.", }, }, + { + dirName: "lmstudio", + idHint: "lmstudio", + source: { + source: "./index.ts", + built: "index.js", + }, + packageName: "@openclaw/lmstudio-provider", + packageVersion: "2026.3.14", + packageDescription: "OpenClaw LM Studio provider plugin", + packageManifest: { + extensions: ["./index.ts"], + }, + manifest: { + id: "lmstudio", + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + providers: ["lmstudio"], + providerAuthEnvVars: { + lmstudio: ["LM_API_TOKEN"], + }, + providerAuthChoices: [ + { + provider: "lmstudio", + method: "custom", + choiceId: "lmstudio", + choiceLabel: "LM Studio", + choiceHint: "Local/self-hosted LM Studio server", + groupId: "lmstudio", + groupLabel: "LM Studio", + groupHint: "Self-hosted open-weight models", + }, + ], + }, + }, { dirName: "lobster", idHint: "lobster", diff --git a/src/plugins/bundled-provider-auth-env-vars.generated.ts b/src/plugins/bundled-provider-auth-env-vars.generated.ts index 611f11121c64..8f4a685b573e 100644 --- a/src/plugins/bundled-provider-auth-env-vars.generated.ts +++ b/src/plugins/bundled-provider-auth-env-vars.generated.ts @@ -16,6 +16,7 @@ export const BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { kilocode: ["KILOCODE_API_KEY"], kimi: ["KIMI_API_KEY", "KIMICODE_API_KEY"], "kimi-coding": ["KIMI_API_KEY", "KIMICODE_API_KEY"], + lmstudio: ["LM_API_TOKEN"], minimax: ["MINIMAX_API_KEY"], "minimax-portal": ["MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY"], mistral: ["MISTRAL_API_KEY"], diff --git a/src/plugins/bundled-provider-auth-env-vars.test.ts b/src/plugins/bundled-provider-auth-env-vars.test.ts index bf0d481834be..2e017906a490 100644 --- a/src/plugins/bundled-provider-auth-env-vars.test.ts +++ b/src/plugins/bundled-provider-auth-env-vars.test.ts @@ -51,6 +51,7 @@ describe("bundled provider auth env vars", () => { "MINIMAX_OAUTH_TOKEN", "MINIMAX_API_KEY", ]); + expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.lmstudio).toEqual(["LM_API_TOKEN"]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.openai).toEqual(["OPENAI_API_KEY"]); expect(BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES.fal).toEqual(["FAL_KEY"]); expect("openai-codex" in BUNDLED_PROVIDER_AUTH_ENV_VAR_CANDIDATES).toBe(false); diff --git a/src/plugins/config-state.ts b/src/plugins/config-state.ts index 4566db4f30f5..e270137f0546 100644 --- a/src/plugins/config-state.ts +++ b/src/plugins/config-state.ts @@ -39,6 +39,7 @@ export const BUNDLED_ENABLED_BY_DEFAULT = new Set([ "google", "huggingface", "kilocode", + "lmstudio", "kimi", "minimax", "mistral", diff --git a/src/plugins/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index 546bcbe25ff1..c90270cae72a 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -6,6 +6,8 @@ import { registerProviders, requireProvider } from "./testkit.js"; const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn()); const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); +const buildLmstudioProviderMock = vi.hoisted(() => vi.fn()); +const discoverLmstudioProviderMock = vi.hoisted(() => vi.fn()); const buildVllmProviderMock = vi.hoisted(() => vi.fn()); const buildSglangProviderMock = vi.hoisted(() => vi.fn()); const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); @@ -15,6 +17,7 @@ let runProviderCatalog: typeof import("../provider-discovery.js").runProviderCat let qwenPortalProvider: Awaited>; let githubCopilotProvider: Awaited>; let ollamaProvider: Awaited>; +let lmstudioProvider: Awaited>; let vllmProvider: Awaited>; let sglangProvider: Awaited>; let minimaxProvider: Awaited>; @@ -145,6 +148,7 @@ describe("provider discovery contract", () => { return { ...actual, buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), + buildLmstudioProvider: (...args: unknown[]) => buildLmstudioProviderMock(...args), buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), }; @@ -159,6 +163,13 @@ describe("provider discovery contract", () => { buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), }; }); + vi.doMock("openclaw/plugin-sdk/lmstudio-setup", async () => { + const actual = await vi.importActual("openclaw/plugin-sdk/lmstudio-setup"); + return { + ...actual, + discoverLmstudioProvider: (...args: unknown[]) => discoverLmstudioProviderMock(...args), + }; + }); vi.doMock("openclaw/plugin-sdk/ollama-setup", async () => { const actual = await vi.importActual("openclaw/plugin-sdk/ollama-setup"); return { @@ -172,6 +183,7 @@ describe("provider discovery contract", () => { { default: qwenPortalPlugin }, { default: githubCopilotPlugin }, { default: ollamaPlugin }, + { default: lmstudioPlugin }, { default: vllmPlugin }, { default: sglangPlugin }, { default: minimaxPlugin }, @@ -181,6 +193,7 @@ describe("provider discovery contract", () => { import("../../../extensions/qwen-portal-auth/index.js"), import("../../../extensions/github-copilot/index.js"), import("../../../extensions/ollama/index.js"), + import("../../../extensions/lmstudio/index.js"), import("../../../extensions/vllm/index.js"), import("../../../extensions/sglang/index.js"), import("../../../extensions/minimax/index.js"), @@ -193,6 +206,7 @@ describe("provider discovery contract", () => { "github-copilot", ); ollamaProvider = requireProvider(registerProviders(ollamaPlugin), "ollama"); + lmstudioProvider = requireProvider(registerProviders(lmstudioPlugin), "lmstudio"); vllmProvider = requireProvider(registerProviders(vllmPlugin), "vllm"); sglangProvider = requireProvider(registerProviders(sglangPlugin), "sglang"); minimaxProvider = requireProvider(registerProviders(minimaxPlugin), "minimax"); @@ -209,6 +223,8 @@ describe("provider discovery contract", () => { vi.restoreAllMocks(); resolveCopilotApiTokenMock.mockReset(); buildOllamaProviderMock.mockReset(); + buildLmstudioProviderMock.mockReset(); + discoverLmstudioProviderMock.mockReset(); buildVllmProviderMock.mockReset(); buildSglangProviderMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); @@ -354,7 +370,9 @@ describe("provider discovery contract", () => { }), }), ).resolves.toBeNull(); - expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { quiet: true }); + expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, { + quiet: true, + }); }); it("keeps vLLM self-hosted discovery provider-owned", async () => { @@ -395,6 +413,82 @@ describe("provider discovery contract", () => { }); }); + it("keeps LM Studio self-hosted discovery provider-owned", async () => { + const provider = lmstudioProvider; + discoverLmstudioProviderMock.mockResolvedValueOnce({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + models: [{ id: "qwen3-8b-instruct", name: "Qwen3 8B (MLX, loaded)" }], + }, + }); + + await expect( + runProviderCatalog({ + provider, + config: {}, + env: { + LM_API_TOKEN: "env-lmstudio-key", + } as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ + apiKey: "LM_API_TOKEN", + discoveryApiKey: "env-lmstudio-key", + }), + resolveProviderAuth: () => ({ + apiKey: "LM_API_TOKEN", + discoveryApiKey: "env-lmstudio-key", + mode: "api_key", + source: "env", + }), + }), + ).resolves.toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + models: [{ id: "qwen3-8b-instruct", name: "Qwen3 8B (MLX, loaded)" }], + }, + }); + expect(discoverLmstudioProviderMock).toHaveBeenCalledTimes(1); + }); + + it("keeps LM Studio unauthenticated discovery configs unauthenticated", async () => { + const provider = lmstudioProvider; + discoverLmstudioProviderMock.mockResolvedValueOnce({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [{ id: "qwen3-8b-instruct", name: "Qwen3 8B (MLX, loaded)" }], + }, + }); + + await expect( + runProviderCatalog({ + provider, + config: {}, + env: {} as NodeJS.ProcessEnv, + resolveProviderApiKey: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), + }), + ).resolves.toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [{ id: "qwen3-8b-instruct", name: "Qwen3 8B (MLX, loaded)" }], + }, + }); + expect(discoverLmstudioProviderMock).toHaveBeenCalledTimes(1); + }); + it("keeps SGLang self-hosted discovery provider-owned", async () => { buildSglangProviderMock.mockResolvedValueOnce({ baseUrl: "http://127.0.0.1:30000/v1", diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index 17bb9d5a91c0..0f948c19aded 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -14,6 +14,7 @@ import groqPlugin from "../../../extensions/groq/index.js"; import huggingFacePlugin from "../../../extensions/huggingface/index.js"; import kilocodePlugin from "../../../extensions/kilocode/index.js"; import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js"; +import lmstudioPlugin from "../../../extensions/lmstudio/index.js"; import microsoftPlugin from "../../../extensions/microsoft/index.js"; import minimaxPlugin from "../../../extensions/minimax/index.js"; import mistralPlugin from "../../../extensions/mistral/index.js"; @@ -364,6 +365,7 @@ const bundledProviderPlugins = dedupePlugins([ googlePlugin, huggingFacePlugin, kilocodePlugin, + lmstudioPlugin, kimiCodingPlugin, minimaxPlugin, mistralPlugin, diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index d6f62299495f..4918f9bdada2 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -25,6 +25,7 @@ const CANONICAL_PACKAGE_ID_ALIASES: Record = { "elevenlabs-speech": "elevenlabs", "microsoft-speech": "microsoft", "ollama-provider": "ollama", + "lmstudio-provider": "lmstudio", "sglang-provider": "sglang", "vllm-provider": "vllm", };