From 123a14744aabf6d17dbec5b2831980df9d5e501c Mon Sep 17 00:00:00 2001 From: Rugved Somwanshi Date: Mon, 16 Mar 2026 12:40:36 -0400 Subject: [PATCH 01/14] Add lmstudio integration --- .github/labeler.yml | 4 + docs/concepts/model-providers.md | 32 +- docs/docs.json | 1 + docs/providers/index.md | 1 + docs/providers/lmstudio.md | 144 ++ docs/reference/api-usage-costs.md | 1 + extensions/lmstudio/README.md | 3 + extensions/lmstudio/index.ts | 81 + extensions/lmstudio/openclaw.plugin.json | 24 + extensions/lmstudio/package.json | 12 + package.json | 12 + pnpm-lock.yaml | 2 + scripts/lib/plugin-sdk-entrypoints.json | 3 + src/agents/lmstudio-defaults.ts | 15 + src/agents/lmstudio-models.test.ts | 601 ++++++++ src/agents/lmstudio-models.ts | 513 +++++++ src/agents/lmstudio-runtime.test.ts | 390 +++++ src/agents/lmstudio-runtime.ts | 190 +++ src/agents/memory-search.test.ts | 9 +- src/agents/memory-search.ts | 14 +- src/agents/model-auth-markers.test.ts | 2 + src/agents/model-auth-markers.ts | 2 + src/agents/model-auth.test.ts | 131 ++ src/agents/model-auth.ts | 21 +- .../models-config.providers.discovery.ts | 36 +- ...ls-config.providers.normalize-keys.test.ts | 19 + src/agents/models-config.providers.ts | 12 + src/commands/doctor-memory-search.test.ts | 144 +- src/commands/doctor-memory-search.ts | 65 +- src/commands/lmstudio-setup.test.ts | 1320 +++++++++++++++++ src/commands/lmstudio-setup.ts | 560 +++++++ ...oard-non-interactive.provider-auth.test.ts | 88 +- src/config/schema.help.ts | 4 +- src/config/types.tools.ts | 4 +- src/config/zod-schema.agent-runtime.ts | 2 + src/memory/embeddings-lmstudio.test.ts | 434 ++++++ src/memory/embeddings-lmstudio.ts | 125 ++ src/memory/embeddings.ts | 27 +- src/memory/manager-embedding-ops.ts | 14 + src/memory/manager-sync-ops.ts | 22 +- src/memory/manager.mistral-provider.test.ts | 94 +- src/memory/manager.ts | 12 +- src/plugin-sdk/index.ts | 1 + src/plugin-sdk/lmstudio-defaults.ts | 6 + src/plugin-sdk/lmstudio-setup.ts | 18 + src/plugin-sdk/provider-setup.ts | 1 + src/plugin-sdk/self-hosted-provider-setup.ts | 1 + src/plugin-sdk/subpaths.test.ts | 1 + .../bundled-provider-auth-env-vars.test.ts | 1 + src/plugins/config-state.ts | 1 + .../contracts/discovery.contract.test.ts | 87 +- src/plugins/contracts/registry.ts | 2 + src/plugins/discovery.ts | 1 + 53 files changed, 5272 insertions(+), 38 deletions(-) create mode 100644 docs/providers/lmstudio.md create mode 100644 extensions/lmstudio/README.md create mode 100644 extensions/lmstudio/index.ts create mode 100644 extensions/lmstudio/openclaw.plugin.json create mode 100644 extensions/lmstudio/package.json create mode 100644 src/agents/lmstudio-defaults.ts create mode 100644 src/agents/lmstudio-models.test.ts create mode 100644 src/agents/lmstudio-models.ts create mode 100644 src/agents/lmstudio-runtime.test.ts create mode 100644 src/agents/lmstudio-runtime.ts create mode 100644 src/commands/lmstudio-setup.test.ts create mode 100644 src/commands/lmstudio-setup.ts create mode 100644 src/memory/embeddings-lmstudio.test.ts create mode 100644 src/memory/embeddings-lmstudio.ts create mode 100644 src/plugin-sdk/lmstudio-defaults.ts create mode 100644 src/plugin-sdk/lmstudio-setup.ts 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..b9b9a4104816 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: @@ -547,7 +569,7 @@ Then set a model (replace with one of the IDs returned by `/v1/models`): See [/providers/sglang](/providers/sglang) for details. -### Local proxies (LM Studio, vLLM, LiteLLM, etc.) +### Local proxies (vLLM, LiteLLM, etc.) Example (OpenAI‑compatible): @@ -555,15 +577,15 @@ Example (OpenAI‑compatible): { agents: { defaults: { - model: { primary: "lmstudio/minimax-m2.5-gs32" }, - models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } }, + model: { primary: "localproxy/minimax-m2.5-gs32" }, + models: { "localproxy/minimax-m2.5-gs32": { alias: "Minimax" } }, }, }, models: { providers: { - lmstudio: { + localproxy: { baseUrl: "http://localhost:1234/v1", - apiKey: "LMSTUDIO_KEY", + apiKey: "LOCAL_PROXY_API_KEY", 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..10d793c662a1 --- /dev/null +++ b/docs/providers/lmstudio.md @@ -0,0 +1,144 @@ +--- +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 +``` + +3. OpenClaw requires an LM Studio token value. Set `LM_API_TOKEN`: + +```bash +export LM_API_TOKEN="your-lm-studio-api-token" +``` + +If your LM Studio server does not require 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. If you experience slow first responses, ensure JIT is enabled in LM Studio settings or pre-load your model with `lms load`. 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..805da1652ef5 --- /dev/null +++ b/extensions/lmstudio/index.ts @@ -0,0 +1,81 @@ +import { + emptyPluginConfigSchema, + type OpenClawPluginApi, + type ProviderAuthContext, + type ProviderAuthMethodNonInteractiveContext, + type ProviderAuthResult, + type ProviderDiscoveryContext, +} from "openclaw/plugin-sdk/core"; +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_PROVIDER_LABEL, +} from "openclaw/plugin-sdk/lmstudio-defaults"; + +const PROVIDER_ID = "lmstudio"; + +/** Lazily loads setup helpers so provider wiring stays lightweight at startup. */ +async function loadProviderSetup() { + return await import("openclaw/plugin-sdk/lmstudio-setup"); +} + +const lmstudioPlugin = { + id: PROVIDER_ID, + name: "LM Studio Provider", + description: "Bundled LM Studio provider plugin", + configSchema: emptyPluginConfigSchema(), + 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); + }, + }, + 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", + }, + }, + }); + }, +}; + +export default lmstudioPlugin; 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..f9f5a9a8c634 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,18 @@ "types": "./dist/plugin-sdk/core.d.ts", "default": "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/compat": { + "types": "./dist/plugin-sdk/compat.d.ts", + "default": "./dist/plugin-sdk/compat.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..c77e0b9995cf 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -1,6 +1,9 @@ [ "index", "core", + "compat", + "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..cef15c550c93 --- /dev/null +++ b/src/agents/lmstudio-models.test.ts @@ -0,0 +1,601 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./lmstudio-defaults.js"; +import { + clearLmstudioWarmupCache, + discoverLmstudioModels, + ensureLmstudioModelLoaded, + resolveLmstudioReasoningCapability, + resolveLmstudioInferenceBase, + resolveLmstudioServerBase, + warmupLmstudioModelBestEffort, +} 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", () => { + afterEach(() => { + clearLmstudioWarmupCache(); + fetchWithSsrFGuardMock.mockReset(); + vi.unstubAllGlobals(); + }); + + it("normalizes server and inference base URLs", () => { + expect(resolveLmstudioServerBase()).toBe("http://localhost:1234"); + expect(resolveLmstudioInferenceBase()).toBe("http://localhost:1234/v1"); + expect(resolveLmstudioServerBase("http://localhost:1234/v1/")).toBe("http://localhost:1234"); + expect(resolveLmstudioServerBase("http://localhost:1234/api/v1")).toBe("http://localhost:1234"); + expect(resolveLmstudioInferenceBase("http://localhost:1234/api/v1")).toBe( + "http://localhost:1234/v1", + ); + }); + + it("parses reasoning capability variants and defaults missing reasoning to false", () => { + 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", "on"], + default: "on", + }, + }, + }), + ).toBe(true); + expect( + resolveLmstudioReasoningCapability({ + capabilities: { + reasoning: { + allowed_options: ["off", "low", "on"], + default: "on", + }, + }, + }), + ).toBe(true); + expect( + resolveLmstudioReasoningCapability({ + capabilities: { + reasoning: { + allowed_options: ["off"], + default: "off", + }, + }, + }), + ).toBe(false); + }); + + it("discovers llm models from /api/v1/models and maps metadata", async () => { + const fetchMock = vi.fn(async () => ({ + 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: fetchMock as unknown as typeof fetch, + }); + + 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("ignores malformed loaded_instances entries during discovery", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + max_context_length: 32768, + loaded_instances: [ + null, + {}, + { id: "bad-null", config: null }, + { id: "bad-negative", config: { context_length: -1 } }, + { id: "ok", config: { context_length: 16384 } }, + ], + }, + ], + }), + })); + + const models = await discoverLmstudioModels({ + baseUrl: resolveLmstudioInferenceBase(), + apiKey: "", + quiet: false, + fetchImpl: fetchMock as unknown as typeof fetch, + }); + + expect(models).toHaveLength(1); + expect(models[0]?.contextWindow).toBe(16384); + }); + + it("does not call /models/load when model is already loaded", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + ], + }), + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).resolves.toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstCall = fetchMock.mock.calls.at(0); + expect(firstCall).toBeDefined(); + expect(String(firstCall?.[0])).toBe("http://localhost:1234/api/v1/models"); + expect( + fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), + ).toBe(false); + }); + + it("loads model when loaded_instances payload is malformed", 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", + loaded_instances: [null, {}, { id: "missing-config" }, { id: "bad", config: {} }], + }, + ], + }), + }; + } + if (String(url).endsWith("/api/v1/models/load")) { + return { + ok: true, + json: async () => ({ status: "loaded" }), + }; + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect( + fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), + ).toBe(true); + }); + + it("does not call /models/load when model discovery is unreachable", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => { + throw new Error("connect ECONNREFUSED localhost:1234"); + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).rejects.toThrow("LM Studio model discovery failed"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstCall = fetchMock.mock.calls.at(0); + expect(firstCall).toBeDefined(); + expect(String(firstCall?.[0])).toBe("http://localhost:1234/api/v1/models"); + expect( + fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), + ).toBe(false); + }); + + it("does not call /models/load when model discovery returns an HTTP error", async () => { + const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({ + ok: false, + status: 401, + })); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).rejects.toThrow("LM Studio model discovery failed (401)"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const firstCall = fetchMock.mock.calls.at(0); + expect(firstCall).toBeDefined(); + expect(String(firstCall?.[0])).toBe("http://localhost:1234/api/v1/models"); + expect( + fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), + ).toBe(false); + }); + + it("loads model when not currently loaded and sends default context length", 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", + 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", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lm-token", + 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(); + const loadInit = loadCall?.[1] ?? {}; + expect(loadInit.method).toBe("POST"); + expect(loadInit.headers).toEqual({ + Authorization: "Bearer lm-token", + "Content-Type": "application/json", + }); + expect(loadInit.body).toBe( + JSON.stringify({ + model: "qwen3-8b-instruct", + context_length: LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + }), + ); + }); + + it("clamps model load context length to the advertised model limit", 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", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + 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(); + const loadInit = loadCall?.[1] ?? {}; + expect(loadInit.body).toBe( + JSON.stringify({ + model: "qwen3-8b-instruct", + context_length: 32768, + }), + ); + }); + + it("includes configured headers in discovery and load requests", 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", + loaded_instances: [], + }, + ], + }), + requestInit: init, + }; + } + 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", fetchMock as unknown as typeof fetch); + + 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 preflightCall = fetchMock.mock.calls.at(0); + expect(preflightCall).toBeDefined(); + const preflightInit = preflightCall?.[1]; + expect(preflightInit?.headers).toEqual({ + "X-Proxy-Auth": "required", + Authorization: "Bearer lm-token", + }); + const loadCall = fetchMock.mock.calls.at(1); + expect(loadCall).toBeDefined(); + const loadInit = loadCall?.[1]; + expect(loadInit?.headers).toEqual({ + "X-Proxy-Auth": "required", + Authorization: "Bearer lm-token", + "Content-Type": "application/json", + }); + }); + + it("routes discovery and load through the SSRF guard when a policy is provided", async () => { + const releaseDiscovery = vi.fn(async () => {}); + const releaseLoad = vi.fn(async () => {}); + fetchWithSsrFGuardMock + .mockResolvedValueOnce({ + response: new Response( + JSON.stringify({ + models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + finalUrl: "http://localhost:1234/api/v1/models", + release: releaseDiscovery, + }) + .mockResolvedValueOnce({ + response: new Response(JSON.stringify({ status: "loaded" }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + finalUrl: "http://localhost:1234/api/v1/models/load", + release: releaseLoad, + }); + const directFetchSpy = vi.fn(() => { + throw new Error("raw fetch should not be used when ssrfPolicy is set"); + }); + vi.stubGlobal("fetch", directFetchSpy as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lm-token", + headers: { + "X-Proxy-Auth": "required", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "qwen3-8b-instruct", + }), + ).resolves.toBeUndefined(); + + expect(directFetchSpy).not.toHaveBeenCalled(); + expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + url: "http://localhost:1234/api/v1/models", + policy: { allowedHostnames: ["localhost"] }, + auditContext: "lmstudio-model-discovery", + }), + ); + expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + url: "http://localhost:1234/api/v1/models/load", + policy: { allowedHostnames: ["localhost"] }, + auditContext: "lmstudio-model-load", + }), + ); + expect(releaseDiscovery).toHaveBeenCalledOnce(); + expect(releaseLoad).toHaveBeenCalledOnce(); + }); + + it("throws when model load returns non-success status", async () => { + const fetchMock = vi.fn(async (url: string | URL) => { + if (String(url).endsWith("/api/v1/models")) { + return { + ok: true, + json: async () => ({ + models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], + }), + }; + } + return { + ok: true, + json: async () => ({ status: "queued" }), + }; + }); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + await expect( + ensureLmstudioModelLoaded({ + modelKey: "qwen3-8b-instruct", + }), + ).rejects.toThrow("unexpected status"); + }); + + it("allows lifecycle hooks to clear in-flight warmups", async () => { + const resolvers: Array<() => void> = []; + const fetchMock = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(() => + resolve({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + ], + }), + } as Response), + ); + }), + ); + vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + + warmupLmstudioModelBestEffort({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }); + warmupLmstudioModelBestEffort({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + + clearLmstudioWarmupCache(); + + warmupLmstudioModelBestEffort({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + + for (const resolve of resolvers) { + resolve(); + } + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/src/agents/lmstudio-models.ts b/src/agents/lmstudio-models.ts new file mode 100644 index 000000000000..a5045d77507f --- /dev/null +++ b/src/agents/lmstudio-models.ts @@ -0,0 +1,513 @@ +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; +}; + +const lmstudioWarmupInFlight = new Map>(); + +export function clearLmstudioWarmupCache(): void { + lmstudioWarmupInFlight.clear(); +} + +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(); + } +} + +/** + * Triggers model preload in the background. + * Failures are logged and intentionally do not affect caller flow. + */ +export function warmupLmstudioModelBestEffort(params: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + ssrfPolicy?: SsrFPolicy; + modelKey: string; + timeoutMs?: number; +}): void { + const modelKey = params.modelKey.trim(); + if (!modelKey) { + return; + } + const baseUrl = resolveLmstudioServerBase(params.baseUrl); + // Use a delimiter that cannot collide with URL/model text. + const warmupKey = `${baseUrl}\u0000${modelKey}`; + if (lmstudioWarmupInFlight.has(warmupKey)) { + return; + } + const warmupPromise = ensureLmstudioModelLoaded({ + baseUrl, + apiKey: params.apiKey, + headers: params.headers, + ssrfPolicy: params.ssrfPolicy, + modelKey, + timeoutMs: params.timeoutMs, + }) + .catch((error) => { + log.debug(`LM Studio warmup failed for "${modelKey}" at ${baseUrl}: ${String(error)}`); + // Warmup is best-effort only and should never block LM Studio usage. + }) + .finally(() => { + lmstudioWarmupInFlight.delete(warmupKey); + }); + lmstudioWarmupInFlight.set(warmupKey, warmupPromise); +} diff --git a/src/agents/lmstudio-runtime.test.ts b/src/agents/lmstudio-runtime.test.ts new file mode 100644 index 000000000000..0b24a98c47f2 --- /dev/null +++ b/src/agents/lmstudio-runtime.test.ts @@ -0,0 +1,390 @@ +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), + }; +}); + +describe("lmstudio-runtime", () => { + beforeEach(() => { + resolveApiKeyForProviderMock.mockReset(); + }); + + it("uses shared provider auth precedence for runtime API key resolution", async () => { + resolveApiKeyForProviderMock.mockResolvedValue({ + apiKey: "profile-lmstudio-key", + profileId: "lmstudio:default", + source: "profile:lmstudio:default", + mode: "api-key", + }); + + const config = { + models: { + providers: { + lmstudio: { + apiKey: "LM_API_TOKEN", + api: "openai-completions", + baseUrl: "http://localhost:1234/v1", + models: [], + }, + }, + }, + } as OpenClawConfig; + + await expect( + resolveLmstudioRuntimeApiKey({ + config, + agentDir: "/tmp/lmstudio-agent", + }), + ).resolves.toBe("profile-lmstudio-key"); + + expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ + provider: "lmstudio", + cfg: config, + agentDir: "/tmp/lmstudio-agent", + }); + }); + + it("does not resolve SecretRef config apiKey when profile auth already resolves", async () => { + resolveApiKeyForProviderMock.mockResolvedValue({ + apiKey: "profile-lmstudio-key", + profileId: "lmstudio:default", + source: "profile:lmstudio:default", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + }), + ).resolves.toBe("profile-lmstudio-key"); + }); + + it("normalizes blank runtime API keys to the keyless auth marker", async () => { + resolveApiKeyForProviderMock.mockResolvedValue({ + apiKey: " ", + source: "profile:lmstudio:default", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: {} as OpenClawConfig, + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("falls back to the keyless auth marker when auth resolution fails", async () => { + resolveApiKeyForProviderMock.mockRejectedValue(new Error("missing auth")); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: {} as OpenClawConfig, + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("returns the local no-auth marker when missing auth is allowed for default local LM Studio", async () => { + resolveApiKeyForProviderMock.mockRejectedValue( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: {} as OpenClawConfig, + allowMissingAuth: true, + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("returns the keyless auth marker when missing auth is allowed", async () => { + resolveApiKeyForProviderMock.mockRejectedValue( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: {} as OpenClawConfig, + allowMissingAuth: true, + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("still falls back to the keyless auth marker when auth store access fails", async () => { + resolveApiKeyForProviderMock.mockRejectedValue(new Error("auth profile store unreadable")); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: {} as OpenClawConfig, + allowMissingAuth: true, + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("resolves SecretRef-backed provider apiKey from config", async () => { + await expect( + resolveLmstudioConfiguredApiKey({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { + LM_API_TOKEN: "secretref-lmstudio-key", + }, + }), + ).resolves.toBe("secretref-lmstudio-key"); + }); + + it("does not use local fallback marker when auth is explicitly api-key and apiKey is missing", async () => { + await expect( + resolveLmstudioConfiguredApiKey({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + models: [], + }, + }, + }, + } as OpenClawConfig, + allowLocalFallback: true, + }), + ).resolves.toBeUndefined(); + }); + + it("returns undefined for explicit api-key auth when auth is missing and allowMissingAuth is true", async () => { + resolveApiKeyForProviderMock.mockRejectedValue( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + models: [], + }, + }, + }, + } as OpenClawConfig, + allowMissingAuth: true, + }), + ).resolves.toBeUndefined(); + }); + + describe("resolveLmstudioProviderHeaders", () => { + it("resolves SecretRef-backed provider headers from config", async () => { + const resolved = await resolveLmstudioProviderHeaders({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token", + }, + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + }); + + expect(resolved).toEqual({ + "X-Proxy-Auth": "proxy-token", + }); + }); + + it("throws a path-specific error when a SecretRef header cannot be resolved", async () => { + await expect( + resolveLmstudioProviderHeaders({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + }), + ).rejects.toThrow(/models\.providers\.lmstudio\.headers\.X-Proxy-Auth/i); + }); + }); + + describe("buildLmstudioAuthHeaders", () => { + it("returns undefined when no params produce headers", () => { + expect(buildLmstudioAuthHeaders({})).toBeUndefined(); + }); + + it("adds Authorization header when apiKey is provided", () => { + expect(buildLmstudioAuthHeaders({ apiKey: "sk-test" })).toEqual({ + Authorization: "Bearer sk-test", + }); + }); + + it("trims whitespace from apiKey", () => { + expect(buildLmstudioAuthHeaders({ apiKey: " sk-test " })).toEqual({ + Authorization: "Bearer sk-test", + }); + }); + + it("ignores blank apiKey", () => { + expect(buildLmstudioAuthHeaders({ apiKey: " " })).toBeUndefined(); + }); + + it("does not synthesize Authorization for the local no-auth marker", () => { + expect( + buildLmstudioAuthHeaders({ apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER }), + ).toBeUndefined(); + }); + + it("adds Content-Type when json flag is set", () => { + expect(buildLmstudioAuthHeaders({ json: true })).toEqual({ + "Content-Type": "application/json", + }); + }); + + it("combines apiKey and json headers", () => { + expect(buildLmstudioAuthHeaders({ apiKey: "sk-test", json: true })).toEqual({ + Authorization: "Bearer sk-test", + "Content-Type": "application/json", + }); + }); + + it("merges custom headers with auth headers", () => { + expect( + buildLmstudioAuthHeaders({ + apiKey: "sk-test", + headers: { "X-Proxy": "proxy-token" }, + }), + ).toEqual({ + "X-Proxy": "proxy-token", + Authorization: "Bearer sk-test", + }); + }); + + it("apiKey overrides Authorization in custom headers", () => { + expect( + buildLmstudioAuthHeaders({ + apiKey: "sk-new", + headers: { Authorization: "Bearer sk-old" }, + }), + ).toEqual({ + Authorization: "Bearer sk-new", + }); + }); + + it("apiKey overrides authorization case-insensitively in custom headers", () => { + expect( + buildLmstudioAuthHeaders({ + apiKey: "sk-new", + headers: { authorization: "Bearer sk-old" }, + }), + ).toEqual({ + Authorization: "Bearer sk-new", + }); + }); + + it("preserves explicit Authorization when apiKey is the local no-auth marker", () => { + expect( + buildLmstudioAuthHeaders({ + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + headers: { Authorization: "Bearer proxy-token" }, + }), + ).toEqual({ + Authorization: "Bearer proxy-token", + }); + }); + + it("returns custom headers alone when no apiKey or json", () => { + expect( + buildLmstudioAuthHeaders({ + headers: { "X-Custom": "value" }, + }), + ).toEqual({ + "X-Custom": "value", + }); + }); + }); +}); diff --git a/src/agents/lmstudio-runtime.ts b/src/agents/lmstudio-runtime.ts new file mode 100644 index 000000000000..80ca6bb90991 --- /dev/null +++ b/src/agents/lmstudio-runtime.ts @@ -0,0 +1,190 @@ +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(); + return resolvedApiKey && resolvedApiKey.length > 0 ? resolvedApiKey : await getConfiguredApiKey(); +} 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..7fd58a389e1f 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,10 @@ export function resolveUsableCustomProviderApiKey(params: { if (!customKey) { return null; } + const normalizedProvider = normalizeProviderId(params.provider); + if (normalizedProvider === "lmstudio" && customKey === LMSTUDIO_LOCAL_AUTH_MARKER) { + return { apiKey: customKey, source: "models.json" }; + } if (!isNonSecretApiKeyMarker(customKey)) { return { apiKey: customKey, source: "models.json" }; } @@ -346,10 +351,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 +549,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..90e1a5dd66a6 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -1,6 +1,15 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; +import { + discoverHuggingfaceModels, + HUGGINGFACE_BASE_URL, + HUGGINGFACE_MODEL_CATALOG, + buildHuggingfaceModelDefinition, +} from "./huggingface-models.js"; +import { discoverKilocodeModels } from "./kilocode-models.js"; +import { discoverLmstudioModels, resolveLmstudioInferenceBase } from "./lmstudio-models.js"; import { enrichOllamaModelsWithContext, OLLAMA_DEFAULT_CONTEXT_WINDOW, @@ -121,7 +130,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 +181,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..65a367d58c15 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -20,6 +20,7 @@ import { hasAnthropicVertexAvailableAuth } from "./anthropic-vertex-provider.js" import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { discoverBedrockModels } from "./bedrock-discovery.js"; import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; +import { resolveLmstudioInferenceBase } from "./lmstudio-models.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; export { buildKimiCodingProvider, @@ -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..e1e09732d97f 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,121 @@ 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 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..4ecb3b1402da 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,49 @@ 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 = + hasRemoteApiKey || (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; + } + if (!providerRequiresApiKey(resolved.provider)) { + return; + } // Remote provider — check for API key if (hasRemoteApiKey || (await hasApiKeyForProvider(resolved.provider, cfg, agentDir))) { return; @@ -187,13 +231,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 { @@ -201,6 +254,14 @@ async function hasApiKeyForProvider( } } +function providerRequiresApiKey( + provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "lmstudio" | "ollama", +): boolean { + // Local LM Studio can legitimately run without auth, so doctor should not + // warn purely because it cannot resolve a token from config. + return provider !== "lmstudio"; +} + function providerEnvVar(provider: string): string { switch (provider) { case "openai": @@ -209,6 +270,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..81710b0e09c4 --- /dev/null +++ b/src/commands/lmstudio-setup.test.ts @@ -0,0 +1,1320 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "../agents/lmstudio-defaults.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, + }), + }; +} + +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 runtime: ProviderAuthMethodNonInteractiveContext["runtime"] & { + error: typeof error; + exit: typeof exit; + log: typeof log; + } = { + error, + exit, + log, + }; + 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, + 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 modelId = params.ctx.opts.customModelId?.trim() || "qwen3-8b-instruct"; + return { + agents: { + defaults: { + model: { + primary: `${providerId}/${modelId}`, + }, + }, + }, + models: { + providers: { + [providerId]: { + baseUrl: params.ctx.opts.customBaseUrl ?? "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: [createModel(modelId, "Qwen3 8B")], + }, + }, + }, + }; + }); + }); + + it("non-interactive setup fetches LM Studio models and persists the discovered catalog", 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: "llm", + key: "phi-4", + max_context_length: 32768, + }, + { + type: "embedding", + key: "text-embedding-nomic-embed-text-v1.5", + }, + ], + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(ctx.resolveApiKey).toHaveBeenCalledWith({ + provider: "lmstudio", + flagValue: "lmstudio-test-key", + flagName: "--custom-api-key", + envVar: "LM_API_TOKEN", + envVarName: "LM_API_TOKEN", + }); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-test-key", + timeoutMs: 5000, + }); + expect(configureSelfHostedNonInteractiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + opts: expect.objectContaining({ + customBaseUrl: "http://localhost:1234/v1", + }), + }), + providerId: "lmstudio", + providerLabel: "LM Studio", + defaultBaseUrl: "http://localhost:1234/v1", + defaultApiKeyEnvVar: "LM_API_TOKEN", + }), + ); + 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", + name: "Qwen3 8B", + contextWindow: 64000, + maxTokens: 8192, + }, + { + id: "phi-4", + name: "phi-4", + contextWindow: 32768, + maxTokens: 8192, + }, + ], + }); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe( + "lmstudio/qwen3-8b-instruct", + ); + }); + + it("non-interactive setup resolves SecretRef-backed headers during discovery", async () => { + const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; + process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; + try { + const ctx = buildNonInteractiveContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/v1", + customModelId: "qwen3-8b-instruct", + }); + + await configureLmstudioNonInteractive(ctx); + + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-test-key", + headers: { + "X-Proxy-Auth": "proxy-token-from-env", + }, + timeoutMs: 5000, + }); + } finally { + if (prevEnv === undefined) { + delete process.env.LMSTUDIO_PROXY_TOKEN; + } else { + process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; + } + } + }); + + it("non-interactive setup preserves existing LM Studio auth fields when refreshing models", async () => { + const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; + process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; + const existingApiKey = { + source: "env" as const, + provider: "default" as const, + id: "CUSTOM_LMSTUDIO_KEY", + }; + const existingHeaders = { + "X-Proxy-Auth": { + source: "env" as const, + provider: "default" as const, + id: "LMSTUDIO_PROXY_TOKEN", + }, + }; + const ctx = buildNonInteractiveContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: existingApiKey, + headers: existingHeaders, + models: [createModel("old-model", "Old Model")], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/v1", + customModelId: "qwen3-8b-instruct", + }); + + try { + const result = await configureLmstudioNonInteractive(ctx); + + expect(result?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: existingApiKey, + headers: existingHeaders, + models: [expect.objectContaining({ id: "qwen3-8b-instruct" })], + }); + } finally { + if (prevEnv === undefined) { + delete process.env.LMSTUDIO_PROXY_TOKEN; + } else { + process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; + } + } + }); + + it("non-interactive setup rewrites generic helper output to LM Studio responses transport", async () => { + configureSelfHostedNonInteractiveMock.mockResolvedValueOnce({ + agents: { + defaults: { + model: { + primary: "lmstudio/qwen3-8b-instruct", + }, + }, + }, + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }, + }, + }, + }); + + const result = await configureLmstudioNonInteractive(buildNonInteractiveContext()); + + expect(configureSelfHostedNonInteractiveMock).toHaveBeenCalledTimes(1); + expect(result?.models?.providers?.lmstudio?.api).toBe("openai-completions"); + }); + + it("non-interactive setup fails when the requested model is not in the LM Studio catalog", async () => { + const ctx = buildNonInteractiveContext({ + customModelId: "missing-model", + }); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + }, + ], + }); + + await expect(configureLmstudioNonInteractive(ctx)).resolves.toBeNull(); + + expect(ctx.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("LM Studio model missing-model was not found"), + ); + expect(ctx.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Available models: qwen3-8b-instruct"), + ); + expect(ctx.runtime.exit).toHaveBeenCalledWith(1); + expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled(); + }); + + it("interactive setup requires API key and canonicalizes LM Studio base URL", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("lmstudio-test-key"); + const note = vi.fn(); + + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + note, + }); + const mergedConfig = mergeConfigPatch(buildConfig(), result.configPatch); + + expect(result).toMatchObject({ + profiles: [ + { + profileId: "lmstudio:default", + credential: { + type: "api_key", + provider: "lmstudio", + key: "lmstudio-test-key", + }, + }, + ], + }); + expect(mergedConfig).toMatchObject({ + agents: { + defaults: { + models: { + "lmstudio/qwen3-8b-instruct": {}, + }, + }, + }, + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: [ + { + id: "qwen3-8b-instruct", + name: "qwen3-8b-instruct", + reasoning: false, + input: ["text"], + }, + ], + }, + }, + }, + }); + expect(result.configPatch?.models?.mode).toBe("merge"); + const apiKeyPrompt = promptText.mock.calls[1]?.[0] as + | { validate?: (value: string | undefined) => string | undefined } + | undefined; + expect(apiKeyPrompt?.validate?.("")).toBe("Required"); + expect(apiKeyPrompt?.validate?.("lmstudio-test-key")).toBeUndefined(); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-test-key", + timeoutMs: 5000, + }); + expect(note).not.toHaveBeenCalled(); + expect(result.defaultModel).toBe("lmstudio/qwen3-8b-instruct"); + }); + + it("interactive setup resolves SecretRef-backed headers during discovery", async () => { + const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; + process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + try { + await promptAndConfigureLmstudioInteractive({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + promptText, + }); + + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-test-key", + headers: { + "X-Proxy-Auth": "proxy-token-from-env", + }, + timeoutMs: 5000, + }); + } finally { + if (prevEnv === undefined) { + delete process.env.LMSTUDIO_PROXY_TOKEN; + } else { + process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; + } + } + }); + + it("interactive setup preserves existing LM Studio auth fields in config patch", async () => { + const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; + process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + const existingApiKey = { + source: "env" as const, + provider: "default" as const, + id: "CUSTOM_LMSTUDIO_KEY", + }; + const existingHeaders = { + "X-Proxy-Auth": { + source: "env" as const, + provider: "default" as const, + id: "LMSTUDIO_PROXY_TOKEN", + }, + }; + try { + const result = await promptAndConfigureLmstudioInteractive({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: existingApiKey, + headers: existingHeaders, + models: [createModel("old-model", "Old Model")], + }, + }, + }, + } as OpenClawConfig, + promptText, + }); + + expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: existingApiKey, + headers: existingHeaders, + models: [expect.objectContaining({ id: "qwen3-8b-instruct" })], + }); + } finally { + if (prevEnv === undefined) { + delete process.env.LMSTUDIO_PROXY_TOKEN; + } else { + process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; + } + } + }); + + it("interactive setup stores an env keyRef when secret input mode is ref", async () => { + const prevEnv = process.env.LM_API_TOKEN; + process.env.LM_API_TOKEN = "lmstudio-env-test-key"; + const text = vi.fn().mockResolvedValueOnce("http://localhost:1234/v1"); + const select = vi.fn().mockResolvedValueOnce("ref").mockResolvedValueOnce("env"); + const note = vi.fn(); + try { + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + prompter: { + text, + select, + note, + } as unknown as Parameters[0]["prompter"], + }); + + expect(result.profiles[0]).toMatchObject({ + profileId: "lmstudio:default", + credential: { + type: "api_key", + provider: "lmstudio", + keyRef: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", + }, + }, + }); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-env-test-key", + timeoutMs: 5000, + }); + } finally { + if (prevEnv === undefined) { + delete process.env.LM_API_TOKEN; + } else { + process.env.LM_API_TOKEN = prevEnv; + } + } + }); + + it("interactive setup auto-stores LM_API_TOKEN as keyRef when secret mode is implicit and env is set", async () => { + const prevEnv = process.env.LM_API_TOKEN; + process.env.LM_API_TOKEN = "lmstudio-auto-ref-key"; + const text = vi.fn().mockResolvedValueOnce("http://localhost:1234/v1"); + const note = vi.fn(); + try { + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + prompter: { + text, + note, + } as unknown as Parameters[0]["prompter"], + allowSecretRefPrompt: false, + }); + + expect(result.profiles[0]).toMatchObject({ + profileId: "lmstudio:default", + credential: { + type: "api_key", + provider: "lmstudio", + keyRef: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", + }, + }, + }); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-auto-ref-key", + timeoutMs: 5000, + }); + } finally { + if (prevEnv === undefined) { + delete process.env.LM_API_TOKEN; + } else { + process.env.LM_API_TOKEN = prevEnv; + } + } + }); + + it("interactive setup preserves an existing models mode", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("lmstudio-test-key"); + + const result = await promptAndConfigureLmstudioInteractive({ + config: { + models: { + mode: "replace", + providers: {}, + }, + } as OpenClawConfig, + promptText, + }); + + expect(result.configPatch?.models?.mode).toBe("replace"); + }); + + it("interactive setup refreshes provider models and preserves existing agent default entries", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "fresh-model", + display_name: "Fresh Model", + }, + ], + }); + + const config = { + agents: { + defaults: { + models: { + "openai/gpt-5.4": { alias: "gpt" }, + "lmstudio/stale-model": { alias: "stale" }, + "lmstudio/fresh-model": { alias: "fresh" }, + }, + }, + }, + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + models: [createModel("stale-model", "Stale Model")], + }, + }, + }, + } satisfies OpenClawConfig; + + const result = await promptAndConfigureLmstudioInteractive({ + config, + promptText, + }); + const mergedConfig = mergeConfigPatch(config, result.configPatch); + + expect(result.configPatch?.models?.providers?.lmstudio?.models).toMatchObject([ + { + id: "fresh-model", + name: "Fresh Model", + }, + ]); + expect(mergedConfig.agents?.defaults?.models).toEqual({ + "openai/gpt-5.4": { alias: "gpt" }, + "lmstudio/stale-model": { alias: "stale" }, + "lmstudio/fresh-model": { alias: "fresh" }, + }); + expect(result.configPatch?.models?.providers?.lmstudio?.models).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: "stale-model" })]), + ); + expect(result.defaultModel).toBe("lmstudio/fresh-model"); + }); + + it("interactive setup prefers the canonical LM Studio default when it is discovered", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "other-model", + }, + { + type: "llm", + key: "qwen/qwen3.5-9b", + }, + ], + }); + + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + }); + + expect(result.defaultModel).toBe("lmstudio/qwen/qwen3.5-9b"); + }); + + it("interactive setup prefers loaded instance context length over max_context_length", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + max_context_length: 262144, + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + ], + }); + + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + }); + + expect(result.configPatch?.models?.providers?.lmstudio?.models).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "qwen3-8b-instruct", + contextWindow: 64000, + maxTokens: 8192, + }), + ]), + ); + }); + + it("skips discovery fetch 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, + apiKey: "LM_API_TOKEN", + discoveryApiKey: "env-lmstudio-key", + }), + ); + + expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: explicitModels, + }, + }); + }); + + it("keeps resolved SecretRef headers when explicit models short-circuit discovery", async () => { + const explicitModels = [createModel("qwen3-8b-instruct", "Qwen3 8B")]; + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: explicitModels, + }, + }, + }, + } as OpenClawConfig, + apiKey: "LM_API_TOKEN", + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", + }, + }), + ); + + expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); + expect(result?.provider.headers).toEqual({ + "X-Proxy-Auth": "proxy-token-from-env", + }); + }); + + it("adds a local auth marker when explicit local LM Studio models have no apiKey", 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, + }, + }); + expect(result?.provider).not.toHaveProperty("auth"); + }); + + it("cancels interactive setup when LM Studio is unreachable", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: false, + models: [], + }); + const note = vi.fn(); + + await expect( + promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + note, + }), + ).rejects.toThrow("LM Studio not reachable"); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("LM Studio could not be reached at http://localhost:1234/v1."), + "LM Studio", + ); + }); + + it("cancels interactive setup when LM Studio returns an HTTP error", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 401, + models: [], + }); + const note = vi.fn(); + + await expect( + promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + note, + }), + ).rejects.toThrow("LM Studio discovery failed (401)"); + expect(note).toHaveBeenCalledWith( + expect.stringContaining( + "LM Studio returned HTTP 401 while listing models at http://localhost:1234/v1.", + ), + "LM Studio", + ); + expect(note.mock.calls[0]?.[0]).toContain("Check the base URL and API key"); + }); + + it("cancels interactive setup when no LM Studio models are available", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [{ type: "embedding", key: "text-embedding-nomic-embed-text-v1.5" }], + }); + const note = vi.fn(); + + await expect( + promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + note, + }), + ).rejects.toThrow("No LM Studio models found"); + expect(note).toHaveBeenCalledWith( + expect.stringContaining("No LM Studio LLM models were found at http://localhost:1234/v1."), + "LM Studio", + ); + }); + + it("uses quiet discovery when LM Studio is not explicitly configured and no key is available", 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("uses non-quiet discovery when runtime LM Studio key is available", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [], + }); + + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + apiKey: "LM_API_TOKEN", + discoveryApiKey: "env-lmstudio-key", + }), + ); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: undefined, + apiKey: "env-lmstudio-key", + quiet: false, + }); + expect(result).toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: [], + }, + }); + }); + + it("uses explicit usable LM Studio key for discovery when available", 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: "LM_API_TOKEN", + models: [], + }, + }, + }, + } as OpenClawConfig, + apiKey: undefined, + env: { + LM_API_TOKEN: "env-lmstudio-key", + }, + }), + ); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "env-lmstudio-key", + headers: undefined, + quiet: false, + }); + expect(result?.provider.models?.map((model) => model.id)).toEqual(["qwen3-8b-instruct"]); + }); + + it("resolves SecretRef-backed LM Studio apiKey during discovery", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: { + source: "env", + provider: "default", + id: "LMSTUDIO_DISCOVERY_TOKEN", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { + LMSTUDIO_DISCOVERY_TOKEN: "secretref-lmstudio-key", + }, + }), + ); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "secretref-lmstudio-key", + headers: undefined, + quiet: false, + }); + }); + + it("falls back to keyless discovery when SecretRef-backed LM Studio apiKey cannot be resolved", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: { + source: "env", + provider: "default", + id: "LMSTUDIO_DISCOVERY_TOKEN", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + }), + ); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + headers: undefined, + quiet: false, + }); + }); + + it("adds a local auth marker for discovered authless local LM Studio configs", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + + const result = await discoverLmstudioProvider(buildDiscoveryContext()); + + expect(result).toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }, + }); + expect(result?.provider).not.toHaveProperty("auth"); + }); + + it("adds a local auth marker for discovered authless non-local LM Studio configs", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://lmstudio.internal:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://lmstudio.internal:1234/v1", + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(result).toEqual({ + provider: { + baseUrl: "http://lmstudio.internal:1234/v1", + api: "openai-completions", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }, + }); + expect(result?.provider).not.toHaveProperty("auth"); + }); + + it("forwards configured LM Studio headers during discovery", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + "X-Proxy-Auth": "proxy-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + headers: { + "X-Proxy-Auth": "proxy-token", + }, + quiet: false, + }); + }); + + it("resolves SecretRef-backed LM Studio headers during discovery", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], + }); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", + }, + }), + ); + + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + headers: { + "X-Proxy-Auth": "proxy-token-from-env", + }, + quiet: false, + }); + }); + + it("keeps resolved SecretRef headers in discovered provider output", 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", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", + }, + }), + ); + + expect(result?.provider.headers).toEqual({ + "X-Proxy-Auth": "proxy-token-from-env", + }); + }); +}); diff --git a/src/commands/lmstudio-setup.ts b/src/commands/lmstudio-setup.ts new file mode 100644 index 000000000000..a8ce86a8d9c8 --- /dev/null +++ b/src/commands/lmstudio-setup.ts @@ -0,0 +1,560 @@ +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 { + fetchLmstudioModels, + mapLmstudioWireEntry, + type LmstudioModelWire, + resolveLmstudioInferenceBase, +} from "../agents/lmstudio-models.js"; +import { resolveLmstudioProviderHeaders } from "../agents/lmstudio-runtime.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 type { + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} 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 { buildApiKeyCredential } from "./onboard-auth.credentials.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; + +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 ? "api-key" : undefined; + } + return resolveSecretInputRef({ value: apiKey }).ref ? "api-key" : 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, + }); + if (!discovery.reachable) { + await note?.( + [ + `LM Studio could not be reached at ${baseUrl}.`, + "Start LM Studio (or run lms server start) and re-run setup.", + ].join("\n"), + "LM Studio", + ); + throw new WizardCancelledError("LM Studio not reachable"); + } + if (discovery.status !== undefined && discovery.status >= 400) { + await note?.( + [ + `LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.`, + "Check the base URL and API key, then re-run setup.", + ].join("\n"), + "LM Studio", + ); + throw new WizardCancelledError(`LM Studio discovery failed (${discovery.status})`); + } + const hasUsableModel = discovery.models.some( + (model) => model.type === "llm" && Boolean(model.key?.trim()), + ); + if (!hasUsableModel) { + await note?.( + [ + `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.", + ].join("\n"), + "LM Studio", + ); + throw new WizardCancelledError("No LM Studio models found"); + } + const discoveredModels = mapFetchedLmstudioModelsToCatalog(discovery.models); + const allowlistEntries = mapDiscoveredLmstudioModelsToAllowlistEntries(discoveredModels); + const defaultModel = selectDiscoveredLmstudioDefaultModel(discoveredModels); + + 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: existingProvider?.apiKey ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + models: discoveredModels, + }, + }, + }, + }, + defaultModel, + }; +} + +/** Non-interactive setup path backed by the shared self-hosted helper. */ +export async function configureLmstudioNonInteractive( + ctx: ProviderAuthMethodNonInteractiveContext, +): Promise { + const customBaseUrl = ctx.opts.customBaseUrl?.trim(); + const baseUrl = resolveLmstudioInferenceBase( + customBaseUrl || LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + ); + const normalizedCtx = customBaseUrl + ? { + ...ctx, + opts: { + ...ctx.opts, + customBaseUrl: baseUrl, + }, + } + : ctx; + const modelId = normalizedCtx.opts.customModelId?.trim(); + if (!modelId) { + const configured = await configureOpenAICompatibleSelfHostedProviderNonInteractive({ + ctx: normalizedCtx, + providerId: PROVIDER_ID, + providerLabel: LMSTUDIO_PROVIDER_LABEL, + defaultBaseUrl: LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + defaultApiKeyEnvVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: LMSTUDIO_MODEL_PLACEHOLDER, + }); + 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: 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, + }); + if (!discovery.reachable) { + normalizedCtx.runtime.error( + [ + `LM Studio could not be reached at ${baseUrl}.`, + "Start LM Studio (or run lms server start) and re-run setup.", + ].join("\n"), + ); + normalizedCtx.runtime.exit(1); + return null; + } + if (discovery.status !== undefined && discovery.status >= 400) { + normalizedCtx.runtime.error( + [ + `LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.`, + "Check the base URL and API key, then re-run setup.", + ].join("\n"), + ); + normalizedCtx.runtime.exit(1); + return null; + } + + const discoveredModels = mapFetchedLmstudioModelsToCatalog(discovery.models); + if (discoveredModels.length === 0) { + normalizedCtx.runtime.error( + [ + `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.", + ].join("\n"), + ); + normalizedCtx.runtime.exit(1); + return null; + } + 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 configureOpenAICompatibleSelfHostedProviderNonInteractive({ + ctx: { + ...normalizedCtx, + resolveApiKey: async () => resolved, + }, + providerId: PROVIDER_ID, + providerLabel: LMSTUDIO_PROVIDER_LABEL, + defaultBaseUrl: LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + defaultApiKeyEnvVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: LMSTUDIO_MODEL_PLACEHOLDER, + }); + if (!configured) { + return null; + } + + return { + ...configured, + models: { + ...configured.models, + providers: { + ...configured.models?.providers, + // Keep existing auth marker fields while refreshing LM Studio transport + model catalog. + [PROVIDER_ID]: { + ...existingProvider, + ...configured.models?.providers?.[PROVIDER_ID], + ...(existingProvider?.apiKey ? { apiKey: existingProvider.apiKey } : {}), + ...(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 explicitWithoutHeaders = explicit + ? (() => { + const { headers: _headers, ...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 = + resolvedApiKey ?? + (shouldUseLmstudioApiKeyPlaceholder({ + hasModels: hasExplicitModels, + resolvedApiKey, + }) + ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER + : undefined); + 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 } : {}), + ...(resolveLmstudioProviderAuthMode(persistedApiKey) ? { auth: "api-key" } : {}), + 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 = + resolvedApiKey ?? + (shouldUseLmstudioApiKeyPlaceholder({ + hasModels: models.length > 0, + resolvedApiKey, + }) + ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER + : undefined); + return { + provider: { + ...provider, + ...explicitWithoutHeaders, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + baseUrl: resolveLmstudioInferenceBase(explicit?.baseUrl ?? provider.baseUrl), + ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), + ...(resolveLmstudioProviderAuthMode(persistedApiKey) ? { auth: "api-key" } : {}), + models, + }, + }; +} diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index f5140c38e4eb..e97c9a6771e1 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,88 @@ 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", + 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.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..68cb4130da71 --- /dev/null +++ b/src/memory/embeddings-lmstudio.test.ts @@ -0,0 +1,434 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { createLmstudioEmbeddingProvider } from "./embeddings-lmstudio.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), + }; +}); + +describe("embeddings-lmstudio", () => { + const originalFetch = globalThis.fetch; + + beforeEach(() => { + ensureLmstudioModelLoadedMock.mockReset(); + resolveLmstudioRuntimeApiKeyMock.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + vi.unstubAllEnvs(); + }); + + it("calls /embeddings on LM Studio inference base and ensures model load", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ embedding: [0.1, 0.2] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + 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", + remote: { + headers: { "X-Remote": "remote" }, + }, + }); + + 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", + "X-Remote": "remote", + }), + }), + ); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-lmstudio-key", + headers: { + "X-Provider": "provider", + "X-Remote": "remote", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledTimes(1); + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledWith( + expect.objectContaining({ allowMissingAuth: true }), + ); + }); + + it("resolves SecretRef-backed LM Studio provider headers for embeddings", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + vi.stubEnv("LMSTUDIO_PROXY_TOKEN", "proxy-token"); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ embedding: [0.1, 0.2] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + headers: { + "X-Provider": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + remote: { + headers: { "X-Remote": "remote" }, + }, + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + "X-Provider": "proxy-token", + "X-Remote": "remote", + }), + }), + ); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-lmstudio-key", + headers: { + "X-Provider": "proxy-token", + "X-Remote": "remote", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + }); + + it("uses remote apiKey and remote baseUrl when provided", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ embedding: [1, 2, 3] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const { provider } = await createLmstudioEmbeddingProvider({ + config: {} as OpenClawConfig, + provider: "lmstudio", + model: "", + fallback: "none", + remote: { + baseUrl: "http://localhost:1234", + apiKey: "remote-lmstudio-key", + }, + }); + + await provider.embedBatch(["one", "two"]); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer remote-lmstudio-key", + }), + }), + ); + expect(resolveLmstudioRuntimeApiKeyMock).not.toHaveBeenCalled(); + }); + + it("keeps resolved api key when header overrides include Authorization", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ embedding: [1, 2, 3] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + headers: { Authorization: "Bearer provider-override" }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + remote: { + headers: { Authorization: "Bearer remote-override" }, + }, + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer profile-lmstudio-key", + }), + }), + ); + }); + + it("works without an API key for keyless local LM Studio", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue(undefined); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ embedding: [1, 2, 3] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + 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"); + + // No Authorization header when key is absent. + 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 LM Studio auth is api-key and runtime key is missing", 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 when LM Studio warmup fails", async () => { + ensureLmstudioModelLoadedMock.mockRejectedValue(new Error("warmup failed")); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + data: [{ embedding: [1, 2, 3] }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + 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("does not block provider construction while LM Studio warmup is in flight", async () => { + ensureLmstudioModelLoadedMock.mockImplementation(() => new Promise(() => {})); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + const createPromise = 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", + }); + + const raced = await Promise.race([ + createPromise.then(() => "resolved"), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)), + ]); + + expect(raced).toBe("resolved"); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + }); + + it("surfaces runtime auth resolution failures", async () => { + resolveLmstudioRuntimeApiKeyMock.mockRejectedValue(new Error("missing auth profile")); + + await expect( + createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + apiKey: "LM_API_TOKEN", + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }), + ).rejects.toThrow("missing auth profile"); + expect(ensureLmstudioModelLoadedMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/memory/embeddings-lmstudio.ts b/src/memory/embeddings-lmstudio.ts new file mode 100644 index 000000000000..b32d631d349c --- /dev/null +++ b/src/memory/embeddings-lmstudio.ts @@ -0,0 +1,125 @@ +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"; +import { resolveMemorySecretInputString } from "./secret-input.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 with remote memory override first, then runtime provider auth. + * Returns undefined when no key is configured, and throws on auth lookup failures. */ +async function resolveLmstudioApiKey( + options: EmbeddingProviderOptions, +): Promise { + const remoteApiKey = resolveMemorySecretInputString({ + value: options.remote?.apiKey, + path: "agents.*.memorySearch.remote.apiKey", + }); + if (remoteApiKey) { + return remoteApiKey; + } + 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 best-effort warms the target model. */ +export async function createLmstudioEmbeddingProvider( + options: EmbeddingProviderOptions, +): Promise<{ provider: EmbeddingProvider; client: LmstudioEmbeddingClient }> { + const providerConfig = options.config.models?.providers?.lmstudio; + // Per-call remote override wins, then provider config, then LM Studio default. + const remoteBaseUrl = options.remote?.baseUrl?.trim(); + const providerBaseUrl = providerConfig?.baseUrl?.trim(); + const configuredBaseUrl = + remoteBaseUrl && remoteBaseUrl.length > 0 + ? remoteBaseUrl + : 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, + }); + // Allow remote call-scoped headers to override provider-level defaults. + const headerOverrides = Object.assign({}, providerHeaders, options.remote?.headers); + const headers = + buildLmstudioAuthHeaders({ + apiKey, + json: true, + headers: headerOverrides, + }) ?? {}; + const ssrfPolicy = buildRemoteBaseUrlPolicy(baseUrl); + const client: LmstudioEmbeddingClient = { + baseUrl, + model, + headers, + ssrfPolicy, + }; + + // Warmup is best-effort only; do not block provider construction on cold model load. + void 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..83e2efbfe6d9 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,13 @@ 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"; + remote?: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + }; }): OpenClawConfig { return { agents: { @@ -52,6 +62,7 @@ function buildConfig(params: { memorySearch: { provider: params.provider, model: params.provider === "mistral" ? "mistral/mistral-embed" : "text-embedding-3-small", + remote: params.remote, fallback: params.fallback ?? "none", store: { path: params.indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: false }, @@ -138,7 +149,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 +195,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 +223,67 @@ 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", + remote: { + baseUrl: "https://openai-proxy.example/v1", + apiKey: "proxy-key", + headers: { "X-Proxy-Auth": "proxy-header" }, + }, + }); + 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; + remote?: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + }; + } + | undefined; + expect(fallbackCall?.provider).toBe("lmstudio"); + expect(fallbackCall?.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); + expect(fallbackCall?.remote).toBeUndefined(); + }); }); 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/index.ts b/src/plugin-sdk/index.ts index c80dbc37eaf4..8635832dd3d4 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -79,3 +79,4 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { onDiagnosticEvent } from "../infra/diagnostic-events.js"; + 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..9843ec1fc386 --- /dev/null +++ b/src/plugin-sdk/lmstudio-setup.ts @@ -0,0 +1,18 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderDiscoveryContext, +} from "../plugins/types.js"; + +export { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_PROVIDER_LABEL, +} from "../agents/lmstudio-defaults.js"; + +export { + configureLmstudioNonInteractive, + discoverLmstudioProvider, + 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/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index d8183d0eaf35..b73e5d4d84f3 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -564,6 +564,7 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); + expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); 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..f000790a7bfd 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -6,6 +6,10 @@ 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 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 +19,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 +150,8 @@ describe("provider discovery contract", () => { return { ...actual, buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), + buildLmstudioProvider: (...args: unknown[]) => buildLmstudioProviderMock(...args), + buildLmstudioProvider: (...args: unknown[]) => buildLmstudioProviderMock(...args), buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), }; @@ -159,6 +166,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 +186,7 @@ describe("provider discovery contract", () => { { default: qwenPortalPlugin }, { default: githubCopilotPlugin }, { default: ollamaPlugin }, + { default: lmstudioPlugin }, { default: vllmPlugin }, { default: sglangPlugin }, { default: minimaxPlugin }, @@ -181,6 +196,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 +209,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 +226,8 @@ describe("provider discovery contract", () => { vi.restoreAllMocks(); resolveCopilotApiTokenMock.mockReset(); buildOllamaProviderMock.mockReset(); + buildLmstudioProviderMock.mockReset(); + discoverLmstudioProviderMock.mockReset(); buildVllmProviderMock.mockReset(); buildSglangProviderMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); @@ -354,7 +373,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 +416,70 @@ 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", + }), + }), + ).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, + }), + }), + ).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..fdb8efd15802 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -15,6 +15,7 @@ import huggingFacePlugin from "../../../extensions/huggingface/index.js"; import kilocodePlugin from "../../../extensions/kilocode/index.js"; import kimiCodingPlugin from "../../../extensions/kimi-coding/index.js"; import microsoftPlugin from "../../../extensions/microsoft/index.js"; +import lmstudioPlugin from "../../../extensions/lmstudio/index.js"; import minimaxPlugin from "../../../extensions/minimax/index.js"; import mistralPlugin from "../../../extensions/mistral/index.js"; import modelStudioPlugin from "../../../extensions/modelstudio/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", }; From 84ea00014a8f7f61d0c75417190cc7f3f376f5fe Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 16:19:25 -0400 Subject: [PATCH 02/14] Remove fluff --- src/agents/lmstudio-models.test.ts | 433 +------ src/agents/lmstudio-models.ts | 46 - src/agents/lmstudio-runtime.test.ts | 404 ++---- .../models-config.providers.discovery.ts | 8 - src/agents/models-config.providers.ts | 2 +- src/commands/lmstudio-setup.test.ts | 1152 ++--------------- src/commands/lmstudio-setup.ts | 135 +- src/config/schema.base.generated.ts | 20 +- src/memory/embeddings-lmstudio.test.ts | 261 +--- src/plugin-sdk/index.ts | 1 - .../bundled-plugin-metadata.generated.ts | 38 + ...undled-provider-auth-env-vars.generated.ts | 1 + .../contracts/discovery.contract.test.ts | 15 +- src/plugins/contracts/registry.ts | 2 +- 14 files changed, 409 insertions(+), 2109 deletions(-) diff --git a/src/agents/lmstudio-models.test.ts b/src/agents/lmstudio-models.test.ts index cef15c550c93..a0363acc0b34 100644 --- a/src/agents/lmstudio-models.test.ts +++ b/src/agents/lmstudio-models.test.ts @@ -1,13 +1,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./lmstudio-defaults.js"; import { - clearLmstudioWarmupCache, discoverLmstudioModels, ensureLmstudioModelLoaded, resolveLmstudioReasoningCapability, resolveLmstudioInferenceBase, resolveLmstudioServerBase, - warmupLmstudioModelBestEffort, } from "./lmstudio-models.js"; import { SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, @@ -25,23 +23,23 @@ vi.mock("../infra/net/fetch-guard.js", async (importOriginal) => { }); describe("lmstudio-models", () => { + const asFetch = (mock: T) => mock as unknown as typeof fetch; + afterEach(() => { - clearLmstudioWarmupCache(); fetchWithSsrFGuardMock.mockReset(); vi.unstubAllGlobals(); }); - it("normalizes server and inference base URLs", () => { + it("normalizes LM Studio base URLs", () => { expect(resolveLmstudioServerBase()).toBe("http://localhost:1234"); expect(resolveLmstudioInferenceBase()).toBe("http://localhost:1234/v1"); - expect(resolveLmstudioServerBase("http://localhost:1234/v1/")).toBe("http://localhost:1234"); expect(resolveLmstudioServerBase("http://localhost:1234/api/v1")).toBe("http://localhost:1234"); expect(resolveLmstudioInferenceBase("http://localhost:1234/api/v1")).toBe( "http://localhost:1234/v1", ); }); - it("parses reasoning capability variants and defaults missing reasoning to false", () => { + it("resolves reasoning capability for supported and unsupported options", () => { expect(resolveLmstudioReasoningCapability({ capabilities: undefined })).toBe(false); expect( resolveLmstudioReasoningCapability({ @@ -53,26 +51,6 @@ describe("lmstudio-models", () => { }, }), ).toBe(true); - expect( - resolveLmstudioReasoningCapability({ - capabilities: { - reasoning: { - allowed_options: ["off", "on"], - default: "on", - }, - }, - }), - ).toBe(true); - expect( - resolveLmstudioReasoningCapability({ - capabilities: { - reasoning: { - allowed_options: ["off", "low", "on"], - default: "on", - }, - }, - }), - ).toBe(true); expect( resolveLmstudioReasoningCapability({ capabilities: { @@ -85,8 +63,8 @@ describe("lmstudio-models", () => { ).toBe(false); }); - it("discovers llm models from /api/v1/models and maps metadata", async () => { - const fetchMock = vi.fn(async () => ({ + it("discovers llm models and maps metadata", async () => { + const fetchMock = vi.fn(async (_url: string | URL) => ({ ok: true, json: async () => ({ models: [ @@ -126,7 +104,7 @@ describe("lmstudio-models", () => { baseUrl: "http://localhost:1234/v1", apiKey: "lm-token", quiet: false, - fetchImpl: fetchMock as unknown as typeof fetch, + fetchImpl: asFetch(fetchMock), }); expect(fetchMock).toHaveBeenCalledWith( @@ -159,40 +137,8 @@ describe("lmstudio-models", () => { }); }); - it("ignores malformed loaded_instances entries during discovery", async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - models: [ - { - type: "llm", - key: "qwen3-8b-instruct", - max_context_length: 32768, - loaded_instances: [ - null, - {}, - { id: "bad-null", config: null }, - { id: "bad-negative", config: { context_length: -1 } }, - { id: "ok", config: { context_length: 16384 } }, - ], - }, - ], - }), - })); - - const models = await discoverLmstudioModels({ - baseUrl: resolveLmstudioInferenceBase(), - apiKey: "", - quiet: false, - fetchImpl: fetchMock as unknown as typeof fetch, - }); - - expect(models).toHaveLength(1); - expect(models[0]?.contextWindow).toBe(16384); - }); - - it("does not call /models/load when model is already loaded", async () => { - const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({ + it("skips model load when already loaded", async () => { + const fetchMock = vi.fn(async (_url: string | URL) => ({ ok: true, json: async () => ({ models: [ @@ -204,7 +150,7 @@ describe("lmstudio-models", () => { ], }), })); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + vi.stubGlobal("fetch", asFetch(fetchMock)); await expect( ensureLmstudioModelLoaded({ @@ -212,152 +158,13 @@ describe("lmstudio-models", () => { modelKey: "qwen3-8b-instruct", }), ).resolves.toBeUndefined(); - expect(fetchMock).toHaveBeenCalledTimes(1); - const firstCall = fetchMock.mock.calls.at(0); - expect(firstCall).toBeDefined(); - expect(String(firstCall?.[0])).toBe("http://localhost:1234/api/v1/models"); - expect( - fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), - ).toBe(false); - }); - - it("loads model when loaded_instances payload is malformed", 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", - loaded_instances: [null, {}, { id: "missing-config" }, { id: "bad", config: {} }], - }, - ], - }), - }; - } - if (String(url).endsWith("/api/v1/models/load")) { - return { - ok: true, - json: async () => ({ status: "loaded" }), - }; - } - throw new Error(`Unexpected fetch URL: ${String(url)}`); - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - await expect( - ensureLmstudioModelLoaded({ - baseUrl: "http://localhost:1234/v1", - modelKey: "qwen3-8b-instruct", - }), - ).resolves.toBeUndefined(); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect( - fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), - ).toBe(true); - }); - - it("does not call /models/load when model discovery is unreachable", async () => { - const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => { - throw new Error("connect ECONNREFUSED localhost:1234"); - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - await expect( - ensureLmstudioModelLoaded({ - baseUrl: "http://localhost:1234/v1", - modelKey: "qwen3-8b-instruct", - }), - ).rejects.toThrow("LM Studio model discovery failed"); - - expect(fetchMock).toHaveBeenCalledTimes(1); - const firstCall = fetchMock.mock.calls.at(0); - expect(firstCall).toBeDefined(); - expect(String(firstCall?.[0])).toBe("http://localhost:1234/api/v1/models"); - expect( - fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), - ).toBe(false); - }); - - it("does not call /models/load when model discovery returns an HTTP error", async () => { - const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({ - ok: false, - status: 401, - })); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - await expect( - ensureLmstudioModelLoaded({ - baseUrl: "http://localhost:1234/v1", - modelKey: "qwen3-8b-instruct", - }), - ).rejects.toThrow("LM Studio model discovery failed (401)"); expect(fetchMock).toHaveBeenCalledTimes(1); - const firstCall = fetchMock.mock.calls.at(0); - expect(firstCall).toBeDefined(); - expect(String(firstCall?.[0])).toBe("http://localhost:1234/api/v1/models"); - expect( - fetchMock.mock.calls.some((call) => String(call[0]).endsWith("/api/v1/models/load")), - ).toBe(false); - }); - - it("loads model when not currently loaded and sends default context length", 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", - 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", fetchMock as unknown as typeof fetch); - - await expect( - ensureLmstudioModelLoaded({ - baseUrl: "http://localhost:1234/v1", - apiKey: "lm-token", - 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(); - const loadInit = loadCall?.[1] ?? {}; - expect(loadInit.method).toBe("POST"); - expect(loadInit.headers).toEqual({ - Authorization: "Bearer lm-token", - "Content-Type": "application/json", - }); - expect(loadInit.body).toBe( - JSON.stringify({ - model: "qwen3-8b-instruct", - context_length: LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, - }), - ); + const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(calledUrls).not.toContain("http://localhost:1234/api/v1/models/load"); }); - it("clamps model load context length to the advertised model limit", async () => { + 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 { @@ -383,54 +190,7 @@ describe("lmstudio-models", () => { } throw new Error(`Unexpected fetch URL: ${String(url)}`); }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - await expect( - ensureLmstudioModelLoaded({ - baseUrl: "http://localhost:1234/v1", - 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(); - const loadInit = loadCall?.[1] ?? {}; - expect(loadInit.body).toBe( - JSON.stringify({ - model: "qwen3-8b-instruct", - context_length: 32768, - }), - ); - }); - - it("includes configured headers in discovery and load requests", 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", - loaded_instances: [], - }, - ], - }), - requestInit: init, - }; - } - 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", fetchMock as unknown as typeof fetch); + vi.stubGlobal("fetch", asFetch(fetchMock)); await expect( ensureLmstudioModelLoaded({ @@ -440,162 +200,45 @@ describe("lmstudio-models", () => { "X-Proxy-Auth": "required", Authorization: "Bearer override", }, - modelKey: "qwen3-8b-instruct", + modelKey: " qwen3-8b-instruct ", }), ).resolves.toBeUndefined(); expect(fetchMock).toHaveBeenCalledTimes(2); - const preflightCall = fetchMock.mock.calls.at(0); - expect(preflightCall).toBeDefined(); - const preflightInit = preflightCall?.[1]; - expect(preflightInit?.headers).toEqual({ - "X-Proxy-Auth": "required", - Authorization: "Bearer lm-token", - }); - const loadCall = fetchMock.mock.calls.at(1); + const loadCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/models/load")); expect(loadCall).toBeDefined(); - const loadInit = loadCall?.[1]; - expect(loadInit?.headers).toEqual({ - "X-Proxy-Auth": "required", - Authorization: "Bearer lm-token", - "Content-Type": "application/json", + 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("routes discovery and load through the SSRF guard when a policy is provided", async () => { - const releaseDiscovery = vi.fn(async () => {}); - const releaseLoad = vi.fn(async () => {}); - fetchWithSsrFGuardMock - .mockResolvedValueOnce({ - response: new Response( - JSON.stringify({ - models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - finalUrl: "http://localhost:1234/api/v1/models", - release: releaseDiscovery, - }) - .mockResolvedValueOnce({ - response: new Response(JSON.stringify({ status: "loaded" }), { - status: 200, - headers: { "content-type": "application/json" }, - }), - finalUrl: "http://localhost:1234/api/v1/models/load", - release: releaseLoad, - }); - const directFetchSpy = vi.fn(() => { - throw new Error("raw fetch should not be used when ssrfPolicy is set"); - }); - vi.stubGlobal("fetch", directFetchSpy as unknown as typeof fetch); + 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", - apiKey: "lm-token", - headers: { - "X-Proxy-Auth": "required", - }, - ssrfPolicy: { allowedHostnames: ["localhost"] }, - modelKey: "qwen3-8b-instruct", - }), - ).resolves.toBeUndefined(); - - expect(directFetchSpy).not.toHaveBeenCalled(); - expect(fetchWithSsrFGuardMock).toHaveBeenCalledTimes(2); - expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( - 1, - expect.objectContaining({ - url: "http://localhost:1234/api/v1/models", - policy: { allowedHostnames: ["localhost"] }, - auditContext: "lmstudio-model-discovery", - }), - ); - expect(fetchWithSsrFGuardMock).toHaveBeenNthCalledWith( - 2, - expect.objectContaining({ - url: "http://localhost:1234/api/v1/models/load", - policy: { allowedHostnames: ["localhost"] }, - auditContext: "lmstudio-model-load", - }), - ); - expect(releaseDiscovery).toHaveBeenCalledOnce(); - expect(releaseLoad).toHaveBeenCalledOnce(); - }); - - it("throws when model load returns non-success status", async () => { - const fetchMock = vi.fn(async (url: string | URL) => { - if (String(url).endsWith("/api/v1/models")) { - return { - ok: true, - json: async () => ({ - models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }], - }), - }; - } - return { - ok: true, - json: async () => ({ status: "queued" }), - }; - }); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); - - await expect( - ensureLmstudioModelLoaded({ modelKey: "qwen3-8b-instruct", }), - ).rejects.toThrow("unexpected status"); - }); - - it("allows lifecycle hooks to clear in-flight warmups", async () => { - const resolvers: Array<() => void> = []; - const fetchMock = vi.fn( - () => - new Promise((resolve) => { - resolvers.push(() => - resolve({ - ok: true, - json: async () => ({ - models: [ - { - type: "llm", - key: "qwen3-8b-instruct", - loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], - }, - ], - }), - } as Response), - ); - }), - ); - vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); + ).rejects.toThrow("LM Studio model discovery failed (401)"); - warmupLmstudioModelBestEffort({ - baseUrl: "http://localhost:1234/v1", - modelKey: "qwen3-8b-instruct", - }); - warmupLmstudioModelBestEffort({ - baseUrl: "http://localhost:1234/v1", - modelKey: "qwen3-8b-instruct", - }); expect(fetchMock).toHaveBeenCalledTimes(1); - - clearLmstudioWarmupCache(); - - warmupLmstudioModelBestEffort({ - baseUrl: "http://localhost:1234/v1", - modelKey: "qwen3-8b-instruct", - }); - expect(fetchMock).toHaveBeenCalledTimes(2); - - for (const resolve of resolvers) { - resolve(); - } - await vi.waitFor(() => { - expect(fetchMock).toHaveBeenCalledTimes(2); - }); }); }); diff --git a/src/agents/lmstudio-models.ts b/src/agents/lmstudio-models.ts index a5045d77507f..5e89d6f984c7 100644 --- a/src/agents/lmstudio-models.ts +++ b/src/agents/lmstudio-models.ts @@ -54,12 +54,6 @@ type FetchLmstudioModelsResult = { error?: unknown; }; -const lmstudioWarmupInFlight = new Map>(); - -export function clearLmstudioWarmupCache(): void { - lmstudioWarmupInFlight.clear(); -} - function normalizeReasoningOption(value: unknown): string | null { if (typeof value !== "string") { return null; @@ -471,43 +465,3 @@ export async function ensureLmstudioModelLoaded(params: { await release(); } } - -/** - * Triggers model preload in the background. - * Failures are logged and intentionally do not affect caller flow. - */ -export function warmupLmstudioModelBestEffort(params: { - baseUrl?: string; - apiKey?: string; - headers?: Record; - ssrfPolicy?: SsrFPolicy; - modelKey: string; - timeoutMs?: number; -}): void { - const modelKey = params.modelKey.trim(); - if (!modelKey) { - return; - } - const baseUrl = resolveLmstudioServerBase(params.baseUrl); - // Use a delimiter that cannot collide with URL/model text. - const warmupKey = `${baseUrl}\u0000${modelKey}`; - if (lmstudioWarmupInFlight.has(warmupKey)) { - return; - } - const warmupPromise = ensureLmstudioModelLoaded({ - baseUrl, - apiKey: params.apiKey, - headers: params.headers, - ssrfPolicy: params.ssrfPolicy, - modelKey, - timeoutMs: params.timeoutMs, - }) - .catch((error) => { - log.debug(`LM Studio warmup failed for "${modelKey}" at ${baseUrl}: ${String(error)}`); - // Warmup is best-effort only and should never block LM Studio usage. - }) - .finally(() => { - lmstudioWarmupInFlight.delete(warmupKey); - }); - lmstudioWarmupInFlight.set(warmupKey, warmupPromise); -} diff --git a/src/agents/lmstudio-runtime.test.ts b/src/agents/lmstudio-runtime.test.ts index 0b24a98c47f2..42af988cb08a 100644 --- a/src/agents/lmstudio-runtime.test.ts +++ b/src/agents/lmstudio-runtime.test.ts @@ -18,79 +18,34 @@ vi.mock("./model-auth.js", async (importOriginal) => { }; }); -describe("lmstudio-runtime", () => { - beforeEach(() => { - resolveApiKeyForProviderMock.mockReset(); - }); - - it("uses shared provider auth precedence for runtime API key resolution", async () => { - resolveApiKeyForProviderMock.mockResolvedValue({ - apiKey: "profile-lmstudio-key", - profileId: "lmstudio:default", - source: "profile:lmstudio:default", - mode: "api-key", - }); - - const config = { - models: { - providers: { - lmstudio: { - apiKey: "LM_API_TOKEN", - api: "openai-completions", - baseUrl: "http://localhost:1234/v1", - models: [], - }, +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; - - await expect( - resolveLmstudioRuntimeApiKey({ - config, - agentDir: "/tmp/lmstudio-agent", - }), - ).resolves.toBe("profile-lmstudio-key"); - - expect(resolveApiKeyForProviderMock).toHaveBeenCalledWith({ - provider: "lmstudio", - cfg: config, - agentDir: "/tmp/lmstudio-agent", - }); - }); - - it("does not resolve SecretRef config apiKey when profile auth already resolves", async () => { - resolveApiKeyForProviderMock.mockResolvedValue({ - apiKey: "profile-lmstudio-key", - profileId: "lmstudio:default", - source: "profile:lmstudio:default", - mode: "api-key", - }); + }, + } as OpenClawConfig; +} - await expect( - resolveLmstudioRuntimeApiKey({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: { - source: "env", - provider: "default", - id: "LM_API_TOKEN", - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: {}, - }), - ).resolves.toBe("profile-lmstudio-key"); +describe("lmstudio-runtime", () => { + beforeEach(() => { + resolveApiKeyForProviderMock.mockReset(); }); - it("normalizes blank runtime API keys to the keyless auth marker", async () => { - resolveApiKeyForProviderMock.mockResolvedValue({ + it("falls back to keyless marker for blank runtime auth", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ apiKey: " ", source: "profile:lmstudio:default", mode: "api-key", @@ -103,288 +58,101 @@ describe("lmstudio-runtime", () => { ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); }); - it("falls back to the keyless auth marker when auth resolution fails", async () => { - resolveApiKeyForProviderMock.mockRejectedValue(new Error("missing auth")); - - await expect( - resolveLmstudioRuntimeApiKey({ - config: {} as OpenClawConfig, - }), - ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); - }); - - it("returns the local no-auth marker when missing auth is allowed for default local LM Studio", async () => { - resolveApiKeyForProviderMock.mockRejectedValue( - new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), - ); - - await expect( - resolveLmstudioRuntimeApiKey({ - config: {} as OpenClawConfig, - allowMissingAuth: true, - }), - ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); - }); - - it("returns the keyless auth marker when missing auth is allowed", async () => { + 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: {} as OpenClawConfig, + config: buildLmstudioConfig({ auth: "api-key" }), allowMissingAuth: true, }), - ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); - }); - - it("still falls back to the keyless auth marker when auth store access fails", async () => { - resolveApiKeyForProviderMock.mockRejectedValue(new Error("auth profile store unreadable")); + ).resolves.toBeUndefined(); await expect( - resolveLmstudioRuntimeApiKey({ - config: {} as OpenClawConfig, - allowMissingAuth: true, + resolveLmstudioConfiguredApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + allowLocalFallback: true, }), - ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + ).resolves.toBeUndefined(); }); - it("resolves SecretRef-backed provider apiKey from config", async () => { + 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: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: { - source: "env", - provider: "default", - id: "LM_API_TOKEN", - }, - models: [], - }, - }, + config: buildLmstudioConfig({ + apiKey: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", }, - } as OpenClawConfig, + }), env: { LM_API_TOKEN: "secretref-lmstudio-key", }, }), ).resolves.toBe("secretref-lmstudio-key"); - }); - it("does not use local fallback marker when auth is explicitly api-key and apiKey is missing", async () => { await expect( - resolveLmstudioConfiguredApiKey({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - auth: "api-key", - models: [], - }, - }, - }, - } as OpenClawConfig, - allowLocalFallback: true, + resolveLmstudioProviderHeaders({ + config: buildLmstudioConfig({ headers: headerRef }), + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token", + }, + headers: headerRef, }), - ).resolves.toBeUndefined(); + ).resolves.toEqual({ + "X-Proxy-Auth": "proxy-token", + }); }); - it("returns undefined for explicit api-key auth when auth is missing and allowMissingAuth is true", async () => { - resolveApiKeyForProviderMock.mockRejectedValue( - new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), - ); - + 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( - resolveLmstudioRuntimeApiKey({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - auth: "api-key", - models: [], - }, - }, - }, - } as OpenClawConfig, - allowMissingAuth: true, + resolveLmstudioProviderHeaders({ + config: buildLmstudioConfig({ headers: headerRef }), + env: {}, + headers: headerRef, }), - ).resolves.toBeUndefined(); + ).rejects.toThrow(/models\.providers\.lmstudio\.headers\.X-Proxy-Auth/i); }); - describe("resolveLmstudioProviderHeaders", () => { - it("resolves SecretRef-backed provider headers from config", async () => { - const resolved = await resolveLmstudioProviderHeaders({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: { - LMSTUDIO_PROXY_TOKEN: "proxy-token", - }, + 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: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, + authorization: "Bearer sk-old", + "X-Proxy": "proxy-token", }, - }); - - expect(resolved).toEqual({ - "X-Proxy-Auth": "proxy-token", - }); - }); - - it("throws a path-specific error when a SecretRef header cannot be resolved", async () => { - await expect( - resolveLmstudioProviderHeaders({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: {}, - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - }), - ).rejects.toThrow(/models\.providers\.lmstudio\.headers\.X-Proxy-Auth/i); - }); - }); - - describe("buildLmstudioAuthHeaders", () => { - it("returns undefined when no params produce headers", () => { - expect(buildLmstudioAuthHeaders({})).toBeUndefined(); - }); - - it("adds Authorization header when apiKey is provided", () => { - expect(buildLmstudioAuthHeaders({ apiKey: "sk-test" })).toEqual({ - Authorization: "Bearer sk-test", - }); - }); - - it("trims whitespace from apiKey", () => { - expect(buildLmstudioAuthHeaders({ apiKey: " sk-test " })).toEqual({ - Authorization: "Bearer sk-test", - }); - }); - - it("ignores blank apiKey", () => { - expect(buildLmstudioAuthHeaders({ apiKey: " " })).toBeUndefined(); - }); - - it("does not synthesize Authorization for the local no-auth marker", () => { - expect( - buildLmstudioAuthHeaders({ apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER }), - ).toBeUndefined(); - }); - - it("adds Content-Type when json flag is set", () => { - expect(buildLmstudioAuthHeaders({ json: true })).toEqual({ - "Content-Type": "application/json", - }); - }); - - it("combines apiKey and json headers", () => { - expect(buildLmstudioAuthHeaders({ apiKey: "sk-test", json: true })).toEqual({ - Authorization: "Bearer sk-test", - "Content-Type": "application/json", - }); - }); - - it("merges custom headers with auth headers", () => { - expect( - buildLmstudioAuthHeaders({ - apiKey: "sk-test", - headers: { "X-Proxy": "proxy-token" }, - }), - ).toEqual({ - "X-Proxy": "proxy-token", - Authorization: "Bearer sk-test", - }); - }); - - it("apiKey overrides Authorization in custom headers", () => { - expect( - buildLmstudioAuthHeaders({ - apiKey: "sk-new", - headers: { Authorization: "Bearer sk-old" }, - }), - ).toEqual({ - Authorization: "Bearer sk-new", - }); - }); - - it("apiKey overrides authorization case-insensitively in custom headers", () => { - expect( - buildLmstudioAuthHeaders({ - apiKey: "sk-new", - headers: { authorization: "Bearer sk-old" }, - }), - ).toEqual({ - Authorization: "Bearer sk-new", - }); - }); - - it("preserves explicit Authorization when apiKey is the local no-auth marker", () => { - expect( - buildLmstudioAuthHeaders({ - apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, - headers: { Authorization: "Bearer proxy-token" }, - }), - ).toEqual({ - Authorization: "Bearer proxy-token", - }); - }); - - it("returns custom headers alone when no apiKey or json", () => { - expect( - buildLmstudioAuthHeaders({ - headers: { "X-Custom": "value" }, - }), - ).toEqual({ - "X-Custom": "value", - }); + }), + ).toEqual({ + "Content-Type": "application/json", + "X-Proxy": "proxy-token", + Authorization: "Bearer sk-new", }); }); }); diff --git a/src/agents/models-config.providers.discovery.ts b/src/agents/models-config.providers.discovery.ts index 90e1a5dd66a6..06c2c6ca94cc 100644 --- a/src/agents/models-config.providers.discovery.ts +++ b/src/agents/models-config.providers.discovery.ts @@ -1,14 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { ModelDefinitionConfig } from "../config/types.models.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { KILOCODE_BASE_URL } from "../providers/kilocode-shared.js"; -import { - discoverHuggingfaceModels, - HUGGINGFACE_BASE_URL, - HUGGINGFACE_MODEL_CATALOG, - buildHuggingfaceModelDefinition, -} from "./huggingface-models.js"; -import { discoverKilocodeModels } from "./kilocode-models.js"; import { discoverLmstudioModels, resolveLmstudioInferenceBase } from "./lmstudio-models.js"; import { enrichOllamaModelsWithContext, diff --git a/src/agents/models-config.providers.ts b/src/agents/models-config.providers.ts index 65a367d58c15..13265b6c24c9 100644 --- a/src/agents/models-config.providers.ts +++ b/src/agents/models-config.providers.ts @@ -19,8 +19,8 @@ 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 { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { resolveLmstudioInferenceBase } from "./lmstudio-models.js"; +import { normalizeGoogleModelId, normalizeXaiModelId } from "./model-id-normalization.js"; import { resolveOllamaApiBase } from "./models-config.providers.discovery.js"; export { buildKimiCodingProvider, diff --git a/src/commands/lmstudio-setup.test.ts b/src/commands/lmstudio-setup.test.ts index 81710b0e09c4..ef1b383a14a6 100644 --- a/src/commands/lmstudio-setup.test.ts +++ b/src/commands/lmstudio-setup.test.ts @@ -84,6 +84,12 @@ function buildDiscoveryContext(params?: { apiKey: params?.apiKey, discoveryApiKey: params?.discoveryApiKey, }), + resolveProviderAuth: () => ({ + apiKey: params?.apiKey, + discoveryApiKey: params?.discoveryApiKey, + mode: "none" as const, + source: "none" as const, + }), }; } @@ -105,15 +111,6 @@ function buildNonInteractiveContext(params?: { const error = vi.fn<(...args: unknown[]) => void>(); const exit = vi.fn<(code: number) => void>(); const log = vi.fn<(...args: unknown[]) => void>(); - const runtime: ProviderAuthMethodNonInteractiveContext["runtime"] & { - error: typeof error; - exit: typeof exit; - log: typeof log; - } = { - error, - exit, - log, - }; const resolveApiKey = vi.fn(async () => params?.resolvedApiKey === null ? null @@ -132,7 +129,7 @@ function buildNonInteractiveContext(params?: { customApiKey: params?.customApiKey ?? "lmstudio-test-key", customModelId: params?.customModelId, } as ProviderAuthMethodNonInteractiveContext["opts"], - runtime, + runtime: { error, exit, log }, resolveApiKey, toApiKeyCredential, }; @@ -165,7 +162,15 @@ describe("lmstudio setup", () => { ctx: ProviderAuthMethodNonInteractiveContext; }; const providerId = params.providerId; - const modelId = params.ctx.opts.customModelId?.trim() || "qwen3-8b-instruct"; + 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: { @@ -177,7 +182,7 @@ describe("lmstudio setup", () => { models: { providers: { [providerId]: { - baseUrl: params.ctx.opts.customBaseUrl ?? "http://localhost:1234/v1", + baseUrl: customBaseUrl ?? "http://localhost:1234/v1", api: "openai-completions", auth: "api-key", apiKey: "LM_API_TOKEN", @@ -189,7 +194,7 @@ describe("lmstudio setup", () => { }); }); - it("non-interactive setup fetches LM Studio models and persists the discovered catalog", async () => { + 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", @@ -204,11 +209,6 @@ describe("lmstudio setup", () => { display_name: "Qwen3 8B", loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], }, - { - type: "llm", - key: "phi-4", - max_context_length: 32768, - }, { type: "embedding", key: "text-embedding-nomic-embed-text-v1.5", @@ -218,31 +218,11 @@ describe("lmstudio setup", () => { const result = await configureLmstudioNonInteractive(ctx); - expect(ctx.resolveApiKey).toHaveBeenCalledWith({ - provider: "lmstudio", - flagValue: "lmstudio-test-key", - flagName: "--custom-api-key", - envVar: "LM_API_TOKEN", - envVarName: "LM_API_TOKEN", - }); expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ baseUrl: "http://localhost:1234/v1", apiKey: "lmstudio-test-key", timeoutMs: 5000, }); - expect(configureSelfHostedNonInteractiveMock).toHaveBeenCalledWith( - expect.objectContaining({ - ctx: expect.objectContaining({ - opts: expect.objectContaining({ - customBaseUrl: "http://localhost:1234/v1", - }), - }), - providerId: "lmstudio", - providerLabel: "LM Studio", - defaultBaseUrl: "http://localhost:1234/v1", - defaultApiKeyEnvVar: "LM_API_TOKEN", - }), - ); expect(result?.models?.providers?.lmstudio).toMatchObject({ baseUrl: "http://localhost:1234/v1", api: "openai-completions", @@ -251,15 +231,7 @@ describe("lmstudio setup", () => { models: [ { id: "qwen3-8b-instruct", - name: "Qwen3 8B", contextWindow: 64000, - maxTokens: 8192, - }, - { - id: "phi-4", - name: "phi-4", - contextWindow: 32768, - maxTokens: 8192, }, ], }); @@ -268,193 +240,34 @@ describe("lmstudio setup", () => { ); }); - it("non-interactive setup resolves SecretRef-backed headers during discovery", async () => { - const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; - process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; - try { - const ctx = buildNonInteractiveContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: "LM_API_TOKEN", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - customBaseUrl: "http://localhost:1234/v1", - customModelId: "qwen3-8b-instruct", - }); - - await configureLmstudioNonInteractive(ctx); - - expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "lmstudio-test-key", - headers: { - "X-Proxy-Auth": "proxy-token-from-env", - }, - timeoutMs: 5000, - }); - } finally { - if (prevEnv === undefined) { - delete process.env.LMSTUDIO_PROXY_TOKEN; - } else { - process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; - } - } - }); - - it("non-interactive setup preserves existing LM Studio auth fields when refreshing models", async () => { - const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; - process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; - const existingApiKey = { - source: "env" as const, - provider: "default" as const, - id: "CUSTOM_LMSTUDIO_KEY", - }; - const existingHeaders = { - "X-Proxy-Auth": { - source: "env" as const, - provider: "default" as const, - id: "LMSTUDIO_PROXY_TOKEN", - }, - }; - const ctx = buildNonInteractiveContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: existingApiKey, - headers: existingHeaders, - models: [createModel("old-model", "Old Model")], - }, - }, - }, - } as OpenClawConfig, - customBaseUrl: "http://localhost:1234/v1", - customModelId: "qwen3-8b-instruct", - }); - - try { - const result = await configureLmstudioNonInteractive(ctx); - - expect(result?.models?.providers?.lmstudio).toMatchObject({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: existingApiKey, - headers: existingHeaders, - models: [expect.objectContaining({ id: "qwen3-8b-instruct" })], - }); - } finally { - if (prevEnv === undefined) { - delete process.env.LMSTUDIO_PROXY_TOKEN; - } else { - process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; - } - } - }); - - it("non-interactive setup rewrites generic helper output to LM Studio responses transport", async () => { - configureSelfHostedNonInteractiveMock.mockResolvedValueOnce({ - agents: { - defaults: { - model: { - primary: "lmstudio/qwen3-8b-instruct", - }, - }, - }, - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: "LM_API_TOKEN", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }, - }, - }, - }); - - const result = await configureLmstudioNonInteractive(buildNonInteractiveContext()); - - expect(configureSelfHostedNonInteractiveMock).toHaveBeenCalledTimes(1); - expect(result?.models?.providers?.lmstudio?.api).toBe("openai-completions"); - }); - - it("non-interactive setup fails when the requested model is not in the LM Studio catalog", async () => { + it("non-interactive setup fails when requested model is missing", async () => { const ctx = buildNonInteractiveContext({ customModelId: "missing-model", }); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: true, - status: 200, - models: [ - { - type: "llm", - key: "qwen3-8b-instruct", - }, - ], - }); await expect(configureLmstudioNonInteractive(ctx)).resolves.toBeNull(); expect(ctx.runtime.error).toHaveBeenCalledWith( expect.stringContaining("LM Studio model missing-model was not found"), ); - expect(ctx.runtime.error).toHaveBeenCalledWith( - expect.stringContaining("Available models: qwen3-8b-instruct"), - ); expect(ctx.runtime.exit).toHaveBeenCalledWith(1); expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled(); }); - it("interactive setup requires API key and canonicalizes LM Studio base URL", async () => { + 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 note = vi.fn(); const result = await promptAndConfigureLmstudioInteractive({ config: buildConfig(), promptText, - note, }); const mergedConfig = mergeConfigPatch(buildConfig(), result.configPatch); - expect(result).toMatchObject({ - profiles: [ - { - profileId: "lmstudio:default", - credential: { - type: "api_key", - provider: "lmstudio", - key: "lmstudio-test-key", - }, - }, - ], - }); + expect(result.configPatch?.models?.mode).toBe("merge"); expect(mergedConfig).toMatchObject({ - agents: { - defaults: { - models: { - "lmstudio/qwen3-8b-instruct": {}, - }, - }, - }, models: { providers: { lmstudio: { @@ -462,859 +275,152 @@ describe("lmstudio setup", () => { api: "openai-completions", auth: "api-key", apiKey: "LM_API_TOKEN", - models: [ - { - id: "qwen3-8b-instruct", - name: "qwen3-8b-instruct", - reasoning: false, - input: ["text"], - }, - ], }, }, }, }); - expect(result.configPatch?.models?.mode).toBe("merge"); - const apiKeyPrompt = promptText.mock.calls[1]?.[0] as - | { validate?: (value: string | undefined) => string | undefined } - | undefined; - expect(apiKeyPrompt?.validate?.("")).toBe("Required"); - expect(apiKeyPrompt?.validate?.("lmstudio-test-key")).toBeUndefined(); - expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "lmstudio-test-key", - timeoutMs: 5000, - }); - expect(note).not.toHaveBeenCalled(); 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 resolves SecretRef-backed headers during discovery", async () => { - const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; - process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - try { - await promptAndConfigureLmstudioInteractive({ + 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/v1", - api: "openai-completions", - apiKey: "LM_API_TOKEN", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], + baseUrl: "http://localhost:1234/api/v1/", + models: explicitModels, }, }, }, } as OpenClawConfig, - promptText, - }); + }), + ); - expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: { baseUrl: "http://localhost:1234/v1", - apiKey: "lmstudio-test-key", - headers: { - "X-Proxy-Auth": "proxy-token-from-env", - }, - timeoutMs: 5000, - }); - } finally { - if (prevEnv === undefined) { - delete process.env.LMSTUDIO_PROXY_TOKEN; - } else { - process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; - } - } + api: "openai-completions", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + models: explicitModels, + }, + }); }); - it("interactive setup preserves existing LM Studio auth fields in config patch", async () => { - const prevEnv = process.env.LMSTUDIO_PROXY_TOKEN; - process.env.LMSTUDIO_PROXY_TOKEN = "proxy-token-from-env"; - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - const existingApiKey = { - source: "env" as const, - provider: "default" as const, - id: "CUSTOM_LMSTUDIO_KEY", - }; - const existingHeaders = { - "X-Proxy-Auth": { - source: "env" as const, - provider: "default" as const, - id: "LMSTUDIO_PROXY_TOKEN", - }, - }; - try { - const result = await promptAndConfigureLmstudioInteractive({ + 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: existingApiKey, - headers: existingHeaders, - models: [createModel("old-model", "Old Model")], + apiKey: { + source: "env", + provider: "default", + id: "LMSTUDIO_DISCOVERY_TOKEN", + }, + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], }, }, }, } as OpenClawConfig, - promptText, - }); - - expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: existingApiKey, - headers: existingHeaders, - models: [expect.objectContaining({ id: "qwen3-8b-instruct" })], - }); - } finally { - if (prevEnv === undefined) { - delete process.env.LMSTUDIO_PROXY_TOKEN; - } else { - process.env.LMSTUDIO_PROXY_TOKEN = prevEnv; - } - } - }); - - it("interactive setup stores an env keyRef when secret input mode is ref", async () => { - const prevEnv = process.env.LM_API_TOKEN; - process.env.LM_API_TOKEN = "lmstudio-env-test-key"; - const text = vi.fn().mockResolvedValueOnce("http://localhost:1234/v1"); - const select = vi.fn().mockResolvedValueOnce("ref").mockResolvedValueOnce("env"); - const note = vi.fn(); - try { - const result = await promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - prompter: { - text, - select, - note, - } as unknown as Parameters[0]["prompter"], - }); - - expect(result.profiles[0]).toMatchObject({ - profileId: "lmstudio:default", - credential: { - type: "api_key", - provider: "lmstudio", - keyRef: { - source: "env", - provider: "default", - id: "LM_API_TOKEN", - }, - }, - }); - expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "lmstudio-env-test-key", - timeoutMs: 5000, - }); - } finally { - if (prevEnv === undefined) { - delete process.env.LM_API_TOKEN; - } else { - process.env.LM_API_TOKEN = prevEnv; - } - } - }); - - it("interactive setup auto-stores LM_API_TOKEN as keyRef when secret mode is implicit and env is set", async () => { - const prevEnv = process.env.LM_API_TOKEN; - process.env.LM_API_TOKEN = "lmstudio-auto-ref-key"; - const text = vi.fn().mockResolvedValueOnce("http://localhost:1234/v1"); - const note = vi.fn(); - try { - const result = await promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - prompter: { - text, - note, - } as unknown as Parameters[0]["prompter"], - allowSecretRefPrompt: false, - }); - - expect(result.profiles[0]).toMatchObject({ - profileId: "lmstudio:default", - credential: { - type: "api_key", - provider: "lmstudio", - keyRef: { - source: "env", - provider: "default", - id: "LM_API_TOKEN", - }, + env: { + LMSTUDIO_DISCOVERY_TOKEN: "secretref-lmstudio-key", + LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", }, - }); - expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "lmstudio-auto-ref-key", - timeoutMs: 5000, - }); - } finally { - if (prevEnv === undefined) { - delete process.env.LM_API_TOKEN; - } else { - process.env.LM_API_TOKEN = prevEnv; - } - } - }); - - it("interactive setup preserves an existing models mode", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/api/v1/") - .mockResolvedValueOnce("lmstudio-test-key"); + }), + ); - const result = await promptAndConfigureLmstudioInteractive({ - config: { - models: { - mode: "replace", - providers: {}, - }, - } as OpenClawConfig, - promptText, + expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "secretref-lmstudio-key", + headers: { + "X-Proxy-Auth": "proxy-token-from-env", + }, + quiet: false, }); - - expect(result.configPatch?.models?.mode).toBe("replace"); + expect(result?.provider.models?.map((model) => model.id)).toEqual(["qwen3-8b-instruct"]); }); - it("interactive setup refreshes provider models and preserves existing agent default entries", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: true, - status: 200, - models: [ - { - type: "llm", - key: "fresh-model", - display_name: "Fresh Model", - }, - ], + it("discoverLmstudioProvider uses quiet mode and returns null when unconfigured", async () => { + buildLmstudioProviderMock.mockResolvedValueOnce({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + models: [], }); - const config = { - agents: { - defaults: { - models: { - "openai/gpt-5.4": { alias: "gpt" }, - "lmstudio/stale-model": { alias: "stale" }, - "lmstudio/fresh-model": { alias: "fresh" }, - }, - }, - }, - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: "LM_API_TOKEN", - models: [createModel("stale-model", "Stale Model")], - }, - }, - }, - } satisfies OpenClawConfig; - - const result = await promptAndConfigureLmstudioInteractive({ - config, - promptText, - }); - const mergedConfig = mergeConfigPatch(config, result.configPatch); + const result = await discoverLmstudioProvider(buildDiscoveryContext()); - expect(result.configPatch?.models?.providers?.lmstudio?.models).toMatchObject([ - { - id: "fresh-model", - name: "Fresh Model", - }, - ]); - expect(mergedConfig.agents?.defaults?.models).toEqual({ - "openai/gpt-5.4": { alias: "gpt" }, - "lmstudio/stale-model": { alias: "stale" }, - "lmstudio/fresh-model": { alias: "fresh" }, - }); - expect(result.configPatch?.models?.providers?.lmstudio?.models).not.toEqual( - expect.arrayContaining([expect.objectContaining({ id: "stale-model" })]), - ); - expect(result.defaultModel).toBe("lmstudio/fresh-model"); - }); - - it("interactive setup prefers the canonical LM Studio default when it is discovered", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: true, - status: 200, - models: [ - { - type: "llm", - key: "other-model", - }, - { - type: "llm", - key: "qwen/qwen3.5-9b", - }, - ], - }); - - const result = await promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - promptText, - }); - - expect(result.defaultModel).toBe("lmstudio/qwen/qwen3.5-9b"); - }); - - it("interactive setup prefers loaded instance context length over max_context_length", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: true, - status: 200, - models: [ - { - type: "llm", - key: "qwen3-8b-instruct", - max_context_length: 262144, - loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], - }, - ], - }); - - const result = await promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - promptText, - }); - - expect(result.configPatch?.models?.providers?.lmstudio?.models).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "qwen3-8b-instruct", - contextWindow: 64000, - maxTokens: 8192, - }), - ]), - ); - }); - - it("skips discovery fetch 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, - apiKey: "LM_API_TOKEN", - discoveryApiKey: "env-lmstudio-key", - }), - ); - - expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); - expect(result).toEqual({ - provider: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - auth: "api-key", - apiKey: "LM_API_TOKEN", - models: explicitModels, - }, - }); - }); - - it("keeps resolved SecretRef headers when explicit models short-circuit discovery", async () => { - const explicitModels = [createModel("qwen3-8b-instruct", "Qwen3 8B")]; - const result = await discoverLmstudioProvider( - buildDiscoveryContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: explicitModels, - }, - }, - }, - } as OpenClawConfig, - apiKey: "LM_API_TOKEN", - env: { - LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", - }, - }), - ); - - expect(buildLmstudioProviderMock).not.toHaveBeenCalled(); - expect(result?.provider.headers).toEqual({ - "X-Proxy-Auth": "proxy-token-from-env", - }); - }); - - it("adds a local auth marker when explicit local LM Studio models have no apiKey", 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, - }, - }); - expect(result?.provider).not.toHaveProperty("auth"); - }); - - it("cancels interactive setup when LM Studio is unreachable", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: false, - models: [], - }); - const note = vi.fn(); - - await expect( - promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - promptText, - note, - }), - ).rejects.toThrow("LM Studio not reachable"); - expect(note).toHaveBeenCalledWith( - expect.stringContaining("LM Studio could not be reached at http://localhost:1234/v1."), - "LM Studio", - ); - }); - - it("cancels interactive setup when LM Studio returns an HTTP error", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: true, - status: 401, - models: [], - }); - const note = vi.fn(); - - await expect( - promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - promptText, - note, - }), - ).rejects.toThrow("LM Studio discovery failed (401)"); - expect(note).toHaveBeenCalledWith( - expect.stringContaining( - "LM Studio returned HTTP 401 while listing models at http://localhost:1234/v1.", - ), - "LM Studio", - ); - expect(note.mock.calls[0]?.[0]).toContain("Check the base URL and API key"); - }); - - it("cancels interactive setup when no LM Studio models are available", async () => { - const promptText = vi - .fn() - .mockResolvedValueOnce("http://localhost:1234/v1") - .mockResolvedValueOnce("lmstudio-test-key"); - fetchLmstudioModelsMock.mockResolvedValueOnce({ - reachable: true, - status: 200, - models: [{ type: "embedding", key: "text-embedding-nomic-embed-text-v1.5" }], - }); - const note = vi.fn(); - - await expect( - promptAndConfigureLmstudioInteractive({ - config: buildConfig(), - promptText, - note, - }), - ).rejects.toThrow("No LM Studio models found"); - expect(note).toHaveBeenCalledWith( - expect.stringContaining("No LM Studio LLM models were found at http://localhost:1234/v1."), - "LM Studio", - ); - }); - - it("uses quiet discovery when LM Studio is not explicitly configured and no key is available", 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(buildLmstudioProviderMock).toHaveBeenCalledWith({ + baseUrl: undefined, + apiKey: undefined, + quiet: true, }); expect(result).toBeNull(); }); - - it("uses non-quiet discovery when runtime LM Studio key is available", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - models: [], - }); - - const result = await discoverLmstudioProvider( - buildDiscoveryContext({ - apiKey: "LM_API_TOKEN", - discoveryApiKey: "env-lmstudio-key", - }), - ); - - expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ - baseUrl: undefined, - apiKey: "env-lmstudio-key", - quiet: false, - }); - expect(result).toEqual({ - provider: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - auth: "api-key", - apiKey: "LM_API_TOKEN", - models: [], - }, - }); - }); - - it("uses explicit usable LM Studio key for discovery when available", 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: "LM_API_TOKEN", - models: [], - }, - }, - }, - } as OpenClawConfig, - apiKey: undefined, - env: { - LM_API_TOKEN: "env-lmstudio-key", - }, - }), - ); - - expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "env-lmstudio-key", - headers: undefined, - quiet: false, - }); - expect(result?.provider.models?.map((model) => model.id)).toEqual(["qwen3-8b-instruct"]); - }); - - it("resolves SecretRef-backed LM Studio apiKey during discovery", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }); - - await discoverLmstudioProvider( - buildDiscoveryContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: { - source: "env", - provider: "default", - id: "LMSTUDIO_DISCOVERY_TOKEN", - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: { - LMSTUDIO_DISCOVERY_TOKEN: "secretref-lmstudio-key", - }, - }), - ); - - expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "secretref-lmstudio-key", - headers: undefined, - quiet: false, - }); - }); - - it("falls back to keyless discovery when SecretRef-backed LM Studio apiKey cannot be resolved", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }); - - await discoverLmstudioProvider( - buildDiscoveryContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: { - source: "env", - provider: "default", - id: "LMSTUDIO_DISCOVERY_TOKEN", - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: {}, - }), - ); - - expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: undefined, - headers: undefined, - quiet: false, - }); - }); - - it("adds a local auth marker for discovered authless local LM Studio configs", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }); - - const result = await discoverLmstudioProvider(buildDiscoveryContext()); - - expect(result).toEqual({ - provider: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }, - }); - expect(result?.provider).not.toHaveProperty("auth"); - }); - - it("adds a local auth marker for discovered authless non-local LM Studio configs", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://lmstudio.internal:1234/v1", - api: "openai-completions", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }); - - const result = await discoverLmstudioProvider( - buildDiscoveryContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://lmstudio.internal:1234/v1", - models: [], - }, - }, - }, - } as OpenClawConfig, - }), - ); - - expect(result).toEqual({ - provider: { - baseUrl: "http://lmstudio.internal:1234/v1", - api: "openai-completions", - apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }, - }); - expect(result?.provider).not.toHaveProperty("auth"); - }); - - it("forwards configured LM Studio headers during discovery", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }); - - await discoverLmstudioProvider( - buildDiscoveryContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - headers: { - "X-Proxy-Auth": "proxy-token", - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - }), - ); - - expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: undefined, - headers: { - "X-Proxy-Auth": "proxy-token", - }, - quiet: false, - }); - }); - - it("resolves SecretRef-backed LM Studio headers during discovery", async () => { - buildLmstudioProviderMock.mockResolvedValueOnce({ - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - models: [createModel("qwen3-8b-instruct", "Qwen3 8B")], - }); - - await discoverLmstudioProvider( - buildDiscoveryContext({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - api: "openai-completions", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: { - LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", - }, - }), - ); - - expect(buildLmstudioProviderMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: undefined, - headers: { - "X-Proxy-Auth": "proxy-token-from-env", - }, - quiet: false, - }); - }); - - it("keeps resolved SecretRef headers in discovered provider output", 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", - headers: { - "X-Proxy-Auth": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - env: { - LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", - }, - }), - ); - - expect(result?.provider.headers).toEqual({ - "X-Proxy-Auth": "proxy-token-from-env", - }); - }); }); diff --git a/src/commands/lmstudio-setup.ts b/src/commands/lmstudio-setup.ts index a8ce86a8d9c8..b8db1d7651df 100644 --- a/src/commands/lmstudio-setup.ts +++ b/src/commands/lmstudio-setup.ts @@ -21,6 +21,7 @@ import { projectConfigOntoRuntimeSourceSnapshot, type OpenClawConfig } from "../ 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, @@ -31,7 +32,6 @@ 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 { buildApiKeyCredential } from "./onboard-auth.credentials.js"; import type { SecretInputMode } from "./onboard-types.js"; import { configureOpenAICompatibleSelfHostedProviderNonInteractive } from "./self-hosted-provider-setup.js"; @@ -43,6 +43,45 @@ type ProviderPromptText = (params: { }) => 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; @@ -246,38 +285,10 @@ export async function promptAndConfigureLmstudioInteractive(params: { ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), timeoutMs: 5000, }); - if (!discovery.reachable) { - await note?.( - [ - `LM Studio could not be reached at ${baseUrl}.`, - "Start LM Studio (or run lms server start) and re-run setup.", - ].join("\n"), - "LM Studio", - ); - throw new WizardCancelledError("LM Studio not reachable"); - } - if (discovery.status !== undefined && discovery.status >= 400) { - await note?.( - [ - `LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.`, - "Check the base URL and API key, then re-run setup.", - ].join("\n"), - "LM Studio", - ); - throw new WizardCancelledError(`LM Studio discovery failed (${discovery.status})`); - } - const hasUsableModel = discovery.models.some( - (model) => model.type === "llm" && Boolean(model.key?.trim()), - ); - if (!hasUsableModel) { - await note?.( - [ - `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.", - ].join("\n"), - "LM Studio", - ); - throw new WizardCancelledError("No LM Studio models found"); + 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); @@ -319,7 +330,7 @@ export async function promptAndConfigureLmstudioInteractive(params: { export async function configureLmstudioNonInteractive( ctx: ProviderAuthMethodNonInteractiveContext, ): Promise { - const customBaseUrl = ctx.opts.customBaseUrl?.trim(); + const customBaseUrl = normalizeOptionalSecretInput(ctx.opts.customBaseUrl); const baseUrl = resolveLmstudioInferenceBase( customBaseUrl || LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, ); @@ -332,16 +343,18 @@ export async function configureLmstudioNonInteractive( }, } : ctx; - const modelId = normalizedCtx.opts.customModelId?.trim(); - if (!modelId) { - const configured = await configureOpenAICompatibleSelfHostedProviderNonInteractive({ - ctx: normalizedCtx, + 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; } @@ -366,7 +379,7 @@ export async function configureLmstudioNonInteractive( const resolved = await normalizedCtx.resolveApiKey({ provider: PROVIDER_ID, - flagValue: normalizedCtx.opts.customApiKey, + flagValue: normalizeOptionalSecretInput(normalizedCtx.opts.customApiKey), flagName: "--custom-api-key", envVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, envVarName: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, @@ -387,38 +400,13 @@ export async function configureLmstudioNonInteractive( ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), timeoutMs: 5000, }); - if (!discovery.reachable) { - normalizedCtx.runtime.error( - [ - `LM Studio could not be reached at ${baseUrl}.`, - "Start LM Studio (or run lms server start) and re-run setup.", - ].join("\n"), - ); - normalizedCtx.runtime.exit(1); - return null; - } - if (discovery.status !== undefined && discovery.status >= 400) { - normalizedCtx.runtime.error( - [ - `LM Studio returned HTTP ${discovery.status} while listing models at ${baseUrl}.`, - "Check the base URL and API key, then re-run setup.", - ].join("\n"), - ); + 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.length === 0) { - normalizedCtx.runtime.error( - [ - `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.", - ].join("\n"), - ); - normalizedCtx.runtime.exit(1); - return null; - } if (!discoveredModels.some((model) => model.id === modelId)) { normalizedCtx.runtime.error( [ @@ -434,16 +422,9 @@ export async function configureLmstudioNonInteractive( // state and credential storage are handled consistently. The pre-resolved key // is injected via resolveApiKey to skip a second prompt. The returned config // is then post-patched below to add the discovered model list and base URL. - const configured = await configureOpenAICompatibleSelfHostedProviderNonInteractive({ - ctx: { - ...normalizedCtx, - resolveApiKey: async () => resolved, - }, - 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 configured = await configureShared({ + ...normalizedCtx, + resolveApiKey: async () => resolved, }); if (!configured) { return null; 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/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts index 68cb4130da71..2e060d5cb342 100644 --- a/src/memory/embeddings-lmstudio.test.ts +++ b/src/memory/embeddings-lmstudio.test.ts @@ -23,6 +23,23 @@ vi.mock("../agents/lmstudio-runtime.js", async (importOriginal) => { 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(() => { ensureLmstudioModelLoadedMock.mockReset(); @@ -31,26 +48,13 @@ describe("embeddings-lmstudio", () => { afterEach(() => { globalThis.fetch = originalFetch; - vi.unstubAllEnvs(); }); - it("calls /embeddings on LM Studio inference base and ensures model load", async () => { + it("embeds against inference base and warms model with resolved key", async () => { ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - data: [{ embedding: [0.1, 0.2] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; + const fetchMock = mockEmbeddingFetch([0.1, 0.2]); const { provider } = await createLmstudioEmbeddingProvider({ config: { @@ -97,97 +101,13 @@ describe("embeddings-lmstudio", () => { modelKey: "text-embedding-nomic-embed-text-v1.5", timeoutMs: 120_000, }); - expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledTimes(1); - expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledWith( - expect.objectContaining({ allowMissingAuth: true }), - ); }); - it("resolves SecretRef-backed LM Studio provider headers for embeddings", async () => { - ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); - resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); - vi.stubEnv("LMSTUDIO_PROXY_TOKEN", "proxy-token"); - - const fetchMock = vi.fn(); - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - data: [{ embedding: [0.1, 0.2] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const { provider } = await createLmstudioEmbeddingProvider({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - headers: { - "X-Provider": { - source: "env", - provider: "default", - id: "LMSTUDIO_PROXY_TOKEN", - }, - }, - models: [], - }, - }, - }, - } as OpenClawConfig, - provider: "lmstudio", - model: "text-embedding-nomic-embed-text-v1.5", - fallback: "none", - remote: { - headers: { "X-Remote": "remote" }, - }, - }); - - await provider.embedQuery("hello"); - - expect(fetchMock).toHaveBeenCalledWith( - "http://localhost:1234/v1/embeddings", - expect.objectContaining({ - headers: expect.objectContaining({ - "X-Provider": "proxy-token", - "X-Remote": "remote", - }), - }), - ); - expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ - baseUrl: "http://localhost:1234/v1", - apiKey: "profile-lmstudio-key", - headers: { - "X-Provider": "proxy-token", - "X-Remote": "remote", - }, - ssrfPolicy: { allowedHostnames: ["localhost"] }, - modelKey: "text-embedding-nomic-embed-text-v1.5", - timeoutMs: 120_000, - }); - }); - - it("uses remote apiKey and remote baseUrl when provided", async () => { + it("prefers remote auth/base URL over runtime resolution", async () => { ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - data: [{ embedding: [1, 2, 3] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); const { provider } = await createLmstudioEmbeddingProvider({ config: {} as OpenClawConfig, @@ -213,72 +133,11 @@ describe("embeddings-lmstudio", () => { expect(resolveLmstudioRuntimeApiKeyMock).not.toHaveBeenCalled(); }); - it("keeps resolved api key when header overrides include Authorization", async () => { - ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); - resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - data: [{ embedding: [1, 2, 3] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const { provider } = await createLmstudioEmbeddingProvider({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - headers: { Authorization: "Bearer provider-override" }, - models: [], - }, - }, - }, - } as OpenClawConfig, - provider: "lmstudio", - model: "text-embedding-nomic-embed-text-v1.5", - fallback: "none", - remote: { - headers: { Authorization: "Bearer remote-override" }, - }, - }); - - await provider.embedQuery("hello"); - - expect(fetchMock).toHaveBeenCalledWith( - "http://localhost:1234/v1/embeddings", - expect.objectContaining({ - headers: expect.objectContaining({ - Authorization: "Bearer profile-lmstudio-key", - }), - }), - ); - }); - - it("works without an API key for keyless local LM Studio", async () => { + it("allows keyless local LM Studio", async () => { ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue(undefined); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - data: [{ embedding: [1, 2, 3] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; + const fetchMock = mockEmbeddingFetch([1, 2, 3]); const { provider } = await createLmstudioEmbeddingProvider({ config: { @@ -298,13 +157,12 @@ describe("embeddings-lmstudio", () => { await provider.embedQuery("hello"); - // No Authorization header when key is absent. 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 LM Studio auth is api-key and runtime key is missing", async () => { + 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".'), @@ -334,23 +192,11 @@ describe("embeddings-lmstudio", () => { ); }); - it("continues when LM Studio warmup fails", async () => { + it("continues embedding when warmup fails", async () => { ensureLmstudioModelLoadedMock.mockRejectedValue(new Error("warmup failed")); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); - const fetchMock = vi.fn(); - fetchMock.mockResolvedValue( - new Response( - JSON.stringify({ - data: [{ embedding: [1, 2, 3] }], - }), - { - status: 200, - headers: { "content-type": "application/json" }, - }, - ), - ); - globalThis.fetch = fetchMock as unknown as typeof fetch; + const fetchMock = mockEmbeddingFetch([1, 2, 3]); const { provider } = await createLmstudioEmbeddingProvider({ config: { @@ -378,57 +224,4 @@ describe("embeddings-lmstudio", () => { }), ); }); - - it("does not block provider construction while LM Studio warmup is in flight", async () => { - ensureLmstudioModelLoadedMock.mockImplementation(() => new Promise(() => {})); - resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); - - const createPromise = 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", - }); - - const raced = await Promise.race([ - createPromise.then(() => "resolved"), - new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 200)), - ]); - - expect(raced).toBe("resolved"); - expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); - }); - - it("surfaces runtime auth resolution failures", async () => { - resolveLmstudioRuntimeApiKeyMock.mockRejectedValue(new Error("missing auth profile")); - - await expect( - createLmstudioEmbeddingProvider({ - config: { - models: { - providers: { - lmstudio: { - baseUrl: "http://localhost:1234/v1", - apiKey: "LM_API_TOKEN", - models: [], - }, - }, - }, - } as OpenClawConfig, - provider: "lmstudio", - model: "text-embedding-nomic-embed-text-v1.5", - fallback: "none", - }), - ).rejects.toThrow("missing auth profile"); - expect(ensureLmstudioModelLoadedMock).not.toHaveBeenCalled(); - }); }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 8635832dd3d4..c80dbc37eaf4 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -79,4 +79,3 @@ export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerContextEngine } from "../context-engine/registry.js"; export { delegateCompactionToRuntime } from "../context-engine/delegate.js"; export { onDiagnosticEvent } from "../infra/diagnostic-events.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/contracts/discovery.contract.test.ts b/src/plugins/contracts/discovery.contract.test.ts index f000790a7bfd..c90270cae72a 100644 --- a/src/plugins/contracts/discovery.contract.test.ts +++ b/src/plugins/contracts/discovery.contract.test.ts @@ -8,8 +8,6 @@ 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 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()); @@ -151,7 +149,6 @@ describe("provider discovery contract", () => { ...actual, buildOllamaProvider: (...args: unknown[]) => buildOllamaProviderMock(...args), buildLmstudioProvider: (...args: unknown[]) => buildLmstudioProviderMock(...args), - buildLmstudioProvider: (...args: unknown[]) => buildLmstudioProviderMock(...args), buildVllmProvider: (...args: unknown[]) => buildVllmProviderMock(...args), buildSglangProvider: (...args: unknown[]) => buildSglangProviderMock(...args), }; @@ -438,6 +435,12 @@ describe("provider discovery contract", () => { 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: { @@ -469,6 +472,12 @@ describe("provider discovery contract", () => { apiKey: undefined, discoveryApiKey: undefined, }), + resolveProviderAuth: () => ({ + apiKey: undefined, + discoveryApiKey: undefined, + mode: "none", + source: "none", + }), }), ).resolves.toEqual({ provider: { diff --git a/src/plugins/contracts/registry.ts b/src/plugins/contracts/registry.ts index fdb8efd15802..0f948c19aded 100644 --- a/src/plugins/contracts/registry.ts +++ b/src/plugins/contracts/registry.ts @@ -14,8 +14,8 @@ 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 microsoftPlugin from "../../../extensions/microsoft/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"; import modelStudioPlugin from "../../../extensions/modelstudio/index.js"; From 2f03688632d0f7852ecbd48f207676b7ad03d420 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 16:41:49 -0400 Subject: [PATCH 03/14] Fix comments --- package.json | 4 ---- scripts/lib/plugin-sdk-entrypoints.json | 1 - src/memory/embeddings-lmstudio.test.ts | 24 ++++++++++++++---------- src/memory/embeddings-lmstudio.ts | 21 +++------------------ 4 files changed, 17 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index f9f5a9a8c634..63210eb3658e 100644 --- a/package.json +++ b/package.json @@ -45,10 +45,6 @@ "types": "./dist/plugin-sdk/core.d.ts", "default": "./dist/plugin-sdk/core.js" }, - "./plugin-sdk/compat": { - "types": "./dist/plugin-sdk/compat.d.ts", - "default": "./dist/plugin-sdk/compat.js" - }, "./plugin-sdk/lmstudio-defaults": { "types": "./dist/plugin-sdk/lmstudio-defaults.d.ts", "default": "./dist/plugin-sdk/lmstudio-defaults.js" diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index c77e0b9995cf..692cbd2fd3ca 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -1,7 +1,6 @@ [ "index", "core", - "compat", "lmstudio-defaults", "lmstudio-setup", "ollama-setup", diff --git a/src/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts index 2e060d5cb342..396e4fc976b0 100644 --- a/src/memory/embeddings-lmstudio.test.ts +++ b/src/memory/embeddings-lmstudio.test.ts @@ -71,9 +71,6 @@ describe("embeddings-lmstudio", () => { provider: "lmstudio", model: "lmstudio/text-embedding-nomic-embed-text-v1.5", fallback: "none", - remote: { - headers: { "X-Remote": "remote" }, - }, }); await provider.embedQuery("hello"); @@ -86,7 +83,6 @@ describe("embeddings-lmstudio", () => { "Content-Type": "application/json", Authorization: "Bearer profile-lmstudio-key", "X-Provider": "provider", - "X-Remote": "remote", }), }), ); @@ -95,7 +91,6 @@ describe("embeddings-lmstudio", () => { apiKey: "profile-lmstudio-key", headers: { "X-Provider": "provider", - "X-Remote": "remote", }, ssrfPolicy: { allowedHostnames: ["localhost"] }, modelKey: "text-embedding-nomic-embed-text-v1.5", @@ -103,19 +98,28 @@ describe("embeddings-lmstudio", () => { }); }); - it("prefers remote auth/base URL over runtime resolution", async () => { + it("ignores remote overrides and uses provider config + runtime auth", async () => { ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); const fetchMock = mockEmbeddingFetch([1, 2, 3]); const { provider } = await createLmstudioEmbeddingProvider({ - config: {} as OpenClawConfig, + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + models: [], + }, + }, + }, + } as OpenClawConfig, provider: "lmstudio", model: "", fallback: "none", remote: { - baseUrl: "http://localhost:1234", + baseUrl: "http://localhost:9999", apiKey: "remote-lmstudio-key", }, }); @@ -126,11 +130,11 @@ describe("embeddings-lmstudio", () => { "http://localhost:1234/v1/embeddings", expect.objectContaining({ headers: expect.objectContaining({ - Authorization: "Bearer remote-lmstudio-key", + Authorization: "Bearer profile-key", }), }), ); - expect(resolveLmstudioRuntimeApiKeyMock).not.toHaveBeenCalled(); + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledTimes(1); }); it("allows keyless local LM Studio", async () => { diff --git a/src/memory/embeddings-lmstudio.ts b/src/memory/embeddings-lmstudio.ts index b32d631d349c..9b0d1990025e 100644 --- a/src/memory/embeddings-lmstudio.ts +++ b/src/memory/embeddings-lmstudio.ts @@ -18,7 +18,6 @@ import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normaliz import { createRemoteEmbeddingProvider } from "./embeddings-remote-provider.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy } from "./remote-http.js"; -import { resolveMemorySecretInputString } from "./secret-input.js"; const log = createSubsystemLogger("memory/embeddings"); @@ -39,18 +38,11 @@ function normalizeLmstudioModel(model: string): string { }); } -/** Resolves API key with remote memory override first, then runtime provider auth. +/** 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 remoteApiKey = resolveMemorySecretInputString({ - value: options.remote?.apiKey, - path: "agents.*.memorySearch.remote.apiKey", - }); - if (remoteApiKey) { - return remoteApiKey; - } const authMode = options.config.models?.providers?.lmstudio?.auth; return await resolveLmstudioRuntimeApiKey({ config: options.config, @@ -65,15 +57,9 @@ export async function createLmstudioEmbeddingProvider( options: EmbeddingProviderOptions, ): Promise<{ provider: EmbeddingProvider; client: LmstudioEmbeddingClient }> { const providerConfig = options.config.models?.providers?.lmstudio; - // Per-call remote override wins, then provider config, then LM Studio default. - const remoteBaseUrl = options.remote?.baseUrl?.trim(); const providerBaseUrl = providerConfig?.baseUrl?.trim(); const configuredBaseUrl = - remoteBaseUrl && remoteBaseUrl.length > 0 - ? remoteBaseUrl - : providerBaseUrl && providerBaseUrl.length > 0 - ? providerBaseUrl - : undefined; + providerBaseUrl && providerBaseUrl.length > 0 ? providerBaseUrl : undefined; const baseUrl = resolveLmstudioInferenceBase(configuredBaseUrl); const model = normalizeLmstudioModel(options.model); const apiKey = await resolveLmstudioApiKey(options); @@ -82,8 +68,7 @@ export async function createLmstudioEmbeddingProvider( env: process.env, headers: providerConfig?.headers, }); - // Allow remote call-scoped headers to override provider-level defaults. - const headerOverrides = Object.assign({}, providerHeaders, options.remote?.headers); + const headerOverrides = Object.assign({}, providerHeaders); const headers = buildLmstudioAuthHeaders({ apiKey, From ede614f8cdd7aef345360b29eb69e26a2ff69412 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 17:03:53 -0400 Subject: [PATCH 04/14] more things --- extensions/lmstudio/index.ts | 19 ++++++++----------- src/commands/doctor-memory-search.test.ts | 18 ++++++++++++++++++ src/commands/doctor-memory-search.ts | 3 +-- ...oard-non-interactive.provider-auth.test.ts | 1 + src/memory/embeddings-lmstudio.test.ts | 7 +++++-- 5 files changed, 33 insertions(+), 15 deletions(-) diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts index 805da1652ef5..9b0cf0b2f7c3 100644 --- a/extensions/lmstudio/index.ts +++ b/extensions/lmstudio/index.ts @@ -1,15 +1,15 @@ import { - emptyPluginConfigSchema, + 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, -} from "openclaw/plugin-sdk/core"; -import { - LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, - LMSTUDIO_PROVIDER_LABEL, -} from "openclaw/plugin-sdk/lmstudio-defaults"; +} from "openclaw/plugin-sdk/plugin-entry"; const PROVIDER_ID = "lmstudio"; @@ -18,11 +18,10 @@ async function loadProviderSetup() { return await import("openclaw/plugin-sdk/lmstudio-setup"); } -const lmstudioPlugin = { +export default definePluginEntry({ id: PROVIDER_ID, name: "LM Studio Provider", description: "Bundled LM Studio provider plugin", - configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { api.registerProvider({ id: PROVIDER_ID, @@ -76,6 +75,4 @@ const lmstudioPlugin = { }, }); }, -}; - -export default lmstudioPlugin; +}); diff --git a/src/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index e1e09732d97f..0bc8b5e813ad 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -294,6 +294,24 @@ describe("noteMemorySearchHealth", () => { 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", diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 4ecb3b1402da..0a7512935591 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -87,8 +87,7 @@ export async function noteMemorySearchHealth( if (!lmstudioAuthEnabled && !gatewayProbeWarning) { return; } - const hasLmstudioAuth = - hasRemoteApiKey || (await hasApiKeyForProvider(resolved.provider, cfg, agentDir)); + const hasLmstudioAuth = await hasApiKeyForProvider(resolved.provider, cfg, agentDir); if (lmstudioAuthEnabled && !hasLmstudioAuth) { note( [ diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index e97c9a6771e1..6b77e930394b 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -669,6 +669,7 @@ describe("onboard (non-interactive): provider auth", () => { expect(cfg.models?.providers?.lmstudio).toEqual({ baseUrl: "http://localhost:1234/v1", api: "openai-completions", + auth: "api-key", apiKey: "LM_API_TOKEN", models: [ expect.objectContaining({ diff --git a/src/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts index 396e4fc976b0..455e0dd00c44 100644 --- a/src/memory/embeddings-lmstudio.test.ts +++ b/src/memory/embeddings-lmstudio.test.ts @@ -1,6 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { createLmstudioEmbeddingProvider } from "./embeddings-lmstudio.js"; const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn()); const resolveLmstudioRuntimeApiKeyMock = vi.hoisted(() => vi.fn()); @@ -21,6 +20,8 @@ vi.mock("../agents/lmstudio-runtime.js", async (importOriginal) => { }; }); +let createLmstudioEmbeddingProvider: typeof import("./embeddings-lmstudio.js").createLmstudioEmbeddingProvider; + describe("embeddings-lmstudio", () => { const originalFetch = globalThis.fetch; const jsonResponse = (embedding: number[]) => @@ -41,7 +42,9 @@ describe("embeddings-lmstudio", () => { return fetchMock; } - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ createLmstudioEmbeddingProvider } = await import("./embeddings-lmstudio.js")); ensureLmstudioModelLoadedMock.mockReset(); resolveLmstudioRuntimeApiKeyMock.mockReset(); }); From edc5d370e90eb799a689a69848d079c2169288b1 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 17:12:41 -0400 Subject: [PATCH 05/14] Fix test --- src/memory/manager.mistral-provider.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index 83e2efbfe6d9..812a481b22eb 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -284,6 +284,5 @@ describe("memory manager mistral provider wiring", () => { | undefined; expect(fallbackCall?.provider).toBe("lmstudio"); expect(fallbackCall?.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); - expect(fallbackCall?.remote).toBeUndefined(); }); }); From 63558a3620a911a965f3a1ca86e51d439bb1f9c8 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 17:28:22 -0400 Subject: [PATCH 06/14] Add remote URL and other comments --- src/agents/model-auth.ts | 5 ++++- src/memory/embeddings-lmstudio.test.ts | 31 ++++++++++++++++++++++---- src/memory/embeddings-lmstudio.ts | 17 ++++++++++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 7fd58a389e1f..a21609a151cf 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -83,7 +83,10 @@ export function resolveUsableCustomProviderApiKey(params: { return null; } const normalizedProvider = normalizeProviderId(params.provider); - if (normalizedProvider === "lmstudio" && customKey === LMSTUDIO_LOCAL_AUTH_MARKER) { + if ( + normalizedProvider === "lmstudio" && + (customKey === LMSTUDIO_LOCAL_AUTH_MARKER || customKey === CUSTOM_LOCAL_AUTH_MARKER) + ) { return { apiKey: customKey, source: "models.json" }; } if (!isNonSecretApiKeyMarker(customKey)) { diff --git a/src/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts index 455e0dd00c44..4fabdc938179 100644 --- a/src/memory/embeddings-lmstudio.test.ts +++ b/src/memory/embeddings-lmstudio.test.ts @@ -101,7 +101,7 @@ describe("embeddings-lmstudio", () => { }); }); - it("ignores remote overrides and uses provider config + runtime auth", async () => { + it("honors remote baseUrl/apiKey/header overrides", async () => { ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); @@ -113,6 +113,10 @@ describe("embeddings-lmstudio", () => { providers: { lmstudio: { baseUrl: "http://localhost:1234", + headers: { + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }, models: [], }, }, @@ -124,20 +128,39 @@ describe("embeddings-lmstudio", () => { 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", + "http://localhost:9999/v1/embeddings", expect.objectContaining({ headers: expect.objectContaining({ - Authorization: "Bearer profile-key", + Authorization: "Bearer remote-lmstudio-key", + "X-Provider": "remote", + "X-Config-Only": "from-provider", + "X-Remote-Only": "from-remote", }), }), ); - expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalledTimes(1); + expect(resolveLmstudioRuntimeApiKeyMock).not.toHaveBeenCalled(); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:9999/v1", + apiKey: "remote-lmstudio-key", + headers: { + "X-Provider": "remote", + "X-Config-Only": "from-provider", + "X-Remote-Only": "from-remote", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); }); it("allows keyless local LM Studio", async () => { diff --git a/src/memory/embeddings-lmstudio.ts b/src/memory/embeddings-lmstudio.ts index 9b0d1990025e..885a503e9635 100644 --- a/src/memory/embeddings-lmstudio.ts +++ b/src/memory/embeddings-lmstudio.ts @@ -18,6 +18,7 @@ import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normaliz import { createRemoteEmbeddingProvider } from "./embeddings-remote-provider.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy } from "./remote-http.js"; +import { resolveMemorySecretInputString } from "./secret-input.js"; const log = createSubsystemLogger("memory/embeddings"); @@ -43,6 +44,13 @@ function normalizeLmstudioModel(model: string): string { async function resolveLmstudioApiKey( options: EmbeddingProviderOptions, ): Promise { + const remoteApiKey = resolveMemorySecretInputString({ + value: options.remote?.apiKey, + path: "agents.*.memorySearch.remote.apiKey", + }); + if (remoteApiKey) { + return remoteApiKey; + } const authMode = options.config.models?.providers?.lmstudio?.auth; return await resolveLmstudioRuntimeApiKey({ config: options.config, @@ -57,9 +65,14 @@ export async function createLmstudioEmbeddingProvider( options: EmbeddingProviderOptions, ): Promise<{ provider: EmbeddingProvider; client: LmstudioEmbeddingClient }> { const providerConfig = options.config.models?.providers?.lmstudio; + const remoteBaseUrl = options.remote?.baseUrl?.trim(); const providerBaseUrl = providerConfig?.baseUrl?.trim(); const configuredBaseUrl = - providerBaseUrl && providerBaseUrl.length > 0 ? providerBaseUrl : undefined; + remoteBaseUrl && remoteBaseUrl.length > 0 + ? remoteBaseUrl + : providerBaseUrl && providerBaseUrl.length > 0 + ? providerBaseUrl + : undefined; const baseUrl = resolveLmstudioInferenceBase(configuredBaseUrl); const model = normalizeLmstudioModel(options.model); const apiKey = await resolveLmstudioApiKey(options); @@ -68,7 +81,7 @@ export async function createLmstudioEmbeddingProvider( env: process.env, headers: providerConfig?.headers, }); - const headerOverrides = Object.assign({}, providerHeaders); + const headerOverrides = Object.assign({}, providerHeaders, options.remote?.headers); const headers = buildLmstudioAuthHeaders({ apiKey, From 10b64a42d985815f2f6cf6e18954225db35d823e Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 17:35:37 -0400 Subject: [PATCH 07/14] Update docs --- docs/providers/lmstudio.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/providers/lmstudio.md b/docs/providers/lmstudio.md index 10d793c662a1..2f7e6bb9d30d 100644 --- a/docs/providers/lmstudio.md +++ b/docs/providers/lmstudio.md @@ -30,13 +30,15 @@ lms daemon up 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 your LM Studio server does not require authentication, use any non-empty placeholder value: +If you don't want to use LM Studio with Authentication, use any non-empty placeholder value: ```bash export LM_API_TOKEN="placeholder-key" @@ -141,4 +143,4 @@ If setup reports HTTP 401, verify your API key: ### Just-in-time model loading -LM Studio supports just-in-time (JIT) model loading, where models are loaded on first request. If you experience slow first responses, ensure JIT is enabled in LM Studio settings or pre-load your model with `lms load`. +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. From 7d3d4b5e9d10d24ab30dde42644991c70168d656 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 17:52:52 -0400 Subject: [PATCH 08/14] more cleanup --- src/commands/lmstudio-setup.test.ts | 116 ++++++++++++++++++++++++- src/commands/lmstudio-setup.ts | 84 +++++++++++++----- src/memory/embeddings-lmstudio.test.ts | 57 +++++++++--- src/memory/embeddings-lmstudio.ts | 43 ++++----- 4 files changed, 240 insertions(+), 60 deletions(-) diff --git a/src/commands/lmstudio-setup.test.ts b/src/commands/lmstudio-setup.test.ts index ef1b383a14a6..1d9f5b73759d 100644 --- a/src/commands/lmstudio-setup.test.ts +++ b/src/commands/lmstudio-setup.test.ts @@ -1,5 +1,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "../agents/lmstudio-defaults.js"; +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"; @@ -290,6 +294,36 @@ describe("lmstudio setup", () => { }); }); + 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 = [ { @@ -357,6 +391,36 @@ describe("lmstudio setup", () => { }); }); + 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", @@ -407,6 +471,30 @@ describe("lmstudio setup", () => { 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", @@ -423,4 +511,30 @@ describe("lmstudio setup", () => { }); 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 index b8db1d7651df..79fd5f18effa 100644 --- a/src/commands/lmstudio-setup.ts +++ b/src/commands/lmstudio-setup.ts @@ -15,6 +15,7 @@ import { resolveLmstudioInferenceBase, } from "../agents/lmstudio-models.js"; import { resolveLmstudioProviderHeaders } 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"; @@ -95,11 +96,35 @@ function resolveLmstudioProviderAuthMode( ): ModelProviderConfig["auth"] | undefined { const normalized = normalizeOptionalSecretInput(apiKey); if (normalized !== undefined) { - return normalized && normalized !== LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER ? "api-key" : 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[]; @@ -293,6 +318,13 @@ export async function promptAndConfigureLmstudioInteractive(params: { 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: [ @@ -316,7 +348,7 @@ export async function promptAndConfigureLmstudioInteractive(params: { baseUrl, api: existingProvider?.api ?? "openai-completions", auth: "api-key", - apiKey: existingProvider?.apiKey ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + apiKey: persistedApiKey, models: discoveredModels, }, }, @@ -429,6 +461,13 @@ export async function configureLmstudioNonInteractive( 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, @@ -436,11 +475,11 @@ export async function configureLmstudioNonInteractive( ...configured.models, providers: { ...configured.models?.providers, - // Keep existing auth marker fields while refreshing LM Studio transport + model catalog. + // Preserve compatible auth config while refreshing LM Studio transport + model catalog. [PROVIDER_ID]: { ...existingProvider, ...configured.models?.providers?.[PROVIDER_ID], - ...(existingProvider?.apiKey ? { apiKey: existingProvider.apiKey } : {}), + ...(persistedApiKey !== undefined ? { apiKey: persistedApiKey } : {}), ...(existingProvider?.headers ? { headers: existingProvider.headers } : {}), baseUrl, api: configured.models?.providers?.[PROVIDER_ID]?.api ?? "openai-completions", @@ -457,9 +496,10 @@ export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): P provider: ModelProviderConfig; } | null> { const explicit = ctx.config.models?.providers?.[PROVIDER_ID]; + const explicitAuth = explicit?.auth; const explicitWithoutHeaders = explicit ? (() => { - const { headers: _headers, ...rest } = explicit; + const { headers: _headers, auth: _auth, ...rest } = explicit; return rest; })() : undefined; @@ -483,14 +523,13 @@ export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): P // CLI/runtime-resolved key takes precedence over static provider config key. const resolvedApiKey = apiKey ?? explicit?.apiKey; if (hasExplicitModels && explicitWithoutHeaders) { - const persistedApiKey = - resolvedApiKey ?? - (shouldUseLmstudioApiKeyPlaceholder({ - hasModels: hasExplicitModels, - resolvedApiKey, - }) - ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER - : undefined); + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: resolvedApiKey, + explicitAuth, + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: hasExplicitModels, + }); + const persistedAuth = resolveLmstudioProviderAuthMode(persistedApiKey); return { provider: { ...explicitWithoutHeaders, @@ -499,7 +538,7 @@ export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): P // Keep explicit API unless absent, then fall back to provider default. api: explicitWithoutHeaders.api ?? "openai-completions", ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), - ...(resolveLmstudioProviderAuthMode(persistedApiKey) ? { auth: "api-key" } : {}), + ...(persistedAuth ? { auth: persistedAuth } : {}), models: explicitWithoutHeaders.models, }, }; @@ -519,14 +558,13 @@ export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): P if (models.length === 0 && !apiKey && !explicit?.apiKey) { return null; } - const persistedApiKey = - resolvedApiKey ?? - (shouldUseLmstudioApiKeyPlaceholder({ - hasModels: models.length > 0, - resolvedApiKey, - }) - ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER - : undefined); + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: resolvedApiKey, + explicitAuth, + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: models.length > 0, + }); + const persistedAuth = resolveLmstudioProviderAuthMode(persistedApiKey); return { provider: { ...provider, @@ -534,7 +572,7 @@ export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): P ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), baseUrl: resolveLmstudioInferenceBase(explicit?.baseUrl ?? provider.baseUrl), ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), - ...(resolveLmstudioProviderAuthMode(persistedApiKey) ? { auth: "api-key" } : {}), + ...(persistedAuth ? { auth: persistedAuth } : {}), models, }, }; diff --git a/src/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts index 4fabdc938179..21600e62898b 100644 --- a/src/memory/embeddings-lmstudio.test.ts +++ b/src/memory/embeddings-lmstudio.test.ts @@ -101,7 +101,7 @@ describe("embeddings-lmstudio", () => { }); }); - it("honors remote baseUrl/apiKey/header overrides", async () => { + it("ignores memorySearch remote overrides and uses lmstudio provider config", async () => { ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); @@ -138,24 +138,24 @@ describe("embeddings-lmstudio", () => { await provider.embedBatch(["one", "two"]); expect(fetchMock).toHaveBeenCalledWith( - "http://localhost:9999/v1/embeddings", + "http://localhost:1234/v1/embeddings", expect.objectContaining({ headers: expect.objectContaining({ - Authorization: "Bearer remote-lmstudio-key", - "X-Provider": "remote", + Authorization: "Bearer profile-key", + "X-Provider": "provider", "X-Config-Only": "from-provider", - "X-Remote-Only": "from-remote", }), }), ); - expect(resolveLmstudioRuntimeApiKeyMock).not.toHaveBeenCalled(); + 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:9999/v1", - apiKey: "remote-lmstudio-key", + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-key", headers: { - "X-Provider": "remote", + "X-Provider": "provider", "X-Config-Only": "from-provider", - "X-Remote-Only": "from-remote", }, ssrfPolicy: { allowedHostnames: ["localhost"] }, modelKey: "text-embedding-nomic-embed-text-v1.5", @@ -254,4 +254,41 @@ describe("embeddings-lmstudio", () => { }), ); }); + + 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 Promise.resolve(); + 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 index 885a503e9635..feb9a26b8ece 100644 --- a/src/memory/embeddings-lmstudio.ts +++ b/src/memory/embeddings-lmstudio.ts @@ -18,7 +18,6 @@ import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normaliz import { createRemoteEmbeddingProvider } from "./embeddings-remote-provider.js"; import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.js"; import { buildRemoteBaseUrlPolicy } from "./remote-http.js"; -import { resolveMemorySecretInputString } from "./secret-input.js"; const log = createSubsystemLogger("memory/embeddings"); @@ -44,13 +43,6 @@ function normalizeLmstudioModel(model: string): string { async function resolveLmstudioApiKey( options: EmbeddingProviderOptions, ): Promise { - const remoteApiKey = resolveMemorySecretInputString({ - value: options.remote?.apiKey, - path: "agents.*.memorySearch.remote.apiKey", - }); - if (remoteApiKey) { - return remoteApiKey; - } const authMode = options.config.models?.providers?.lmstudio?.auth; return await resolveLmstudioRuntimeApiKey({ config: options.config, @@ -60,19 +52,17 @@ async function resolveLmstudioApiKey( }); } -/** Creates the LM Studio embedding provider client and best-effort warms the target model. */ +/** 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 remoteBaseUrl = options.remote?.baseUrl?.trim(); 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 = - remoteBaseUrl && remoteBaseUrl.length > 0 - ? remoteBaseUrl - : providerBaseUrl && providerBaseUrl.length > 0 - ? providerBaseUrl - : undefined; + providerBaseUrl && providerBaseUrl.length > 0 ? providerBaseUrl : undefined; const baseUrl = resolveLmstudioInferenceBase(configuredBaseUrl); const model = normalizeLmstudioModel(options.model); const apiKey = await resolveLmstudioApiKey(options); @@ -81,7 +71,7 @@ export async function createLmstudioEmbeddingProvider( env: process.env, headers: providerConfig?.headers, }); - const headerOverrides = Object.assign({}, providerHeaders, options.remote?.headers); + const headerOverrides = Object.assign({}, providerHeaders); const headers = buildLmstudioAuthHeaders({ apiKey, @@ -96,21 +86,22 @@ export async function createLmstudioEmbeddingProvider( ssrfPolicy, }; - // Warmup is best-effort only; do not block provider construction on cold model load. - void ensureLmstudioModelLoaded({ - baseUrl, - apiKey, - headers: headerOverrides, - ssrfPolicy, - modelKey: model, - timeoutMs: 120_000, - }).catch((error) => { + 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({ From 4c7cf484525402add1b5bf685687fa018681b24c Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 18:03:21 -0400 Subject: [PATCH 09/14] Drop custom-local --- src/agents/lmstudio-runtime.test.ts | 15 +++++++++++++++ src/agents/lmstudio-runtime.ts | 9 ++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/agents/lmstudio-runtime.test.ts b/src/agents/lmstudio-runtime.test.ts index 42af988cb08a..b0fe316d6d84 100644 --- a/src/agents/lmstudio-runtime.test.ts +++ b/src/agents/lmstudio-runtime.test.ts @@ -78,6 +78,21 @@ describe("lmstudio-runtime", () => { ).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": { diff --git a/src/agents/lmstudio-runtime.ts b/src/agents/lmstudio-runtime.ts index 80ca6bb90991..4e47ba5673e1 100644 --- a/src/agents/lmstudio-runtime.ts +++ b/src/agents/lmstudio-runtime.ts @@ -186,5 +186,12 @@ export async function resolveLmstudioRuntimeApiKey(params: { } // Normalize empty/whitespace keys to undefined for callers. const resolvedApiKey = resolved.apiKey?.trim(); - return resolvedApiKey && resolvedApiKey.length > 0 ? resolvedApiKey : await getConfiguredApiKey(); + 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; } From 5f57b888abdd5180b344ee2a5c48c927f9ef8083 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 18:22:36 -0400 Subject: [PATCH 10/14] Add dynamic model resolution --- extensions/lmstudio/index.ts | 13 +++++++++++ src/commands/lmstudio-setup.ts | 37 +++++++++++++++++++++++++++++++- src/plugin-sdk/lmstudio-setup.ts | 3 +++ 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts index 9b0cf0b2f7c3..7ba072a851e6 100644 --- a/extensions/lmstudio/index.ts +++ b/extensions/lmstudio/index.ts @@ -9,9 +9,11 @@ import { 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() { @@ -57,6 +59,17 @@ export default definePluginEntry({ 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, diff --git a/src/commands/lmstudio-setup.ts b/src/commands/lmstudio-setup.ts index 79fd5f18effa..f9f1b3a4165c 100644 --- a/src/commands/lmstudio-setup.ts +++ b/src/commands/lmstudio-setup.ts @@ -9,12 +9,16 @@ import { 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 } from "../agents/lmstudio-runtime.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"; @@ -27,6 +31,8 @@ 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"; @@ -577,3 +583,32 @@ export async function discoverLmstudioProvider(ctx: ProviderDiscoveryContext): P }, }; } + +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/plugin-sdk/lmstudio-setup.ts b/src/plugin-sdk/lmstudio-setup.ts index 9843ec1fc386..bf1fc8352744 100644 --- a/src/plugin-sdk/lmstudio-setup.ts +++ b/src/plugin-sdk/lmstudio-setup.ts @@ -4,6 +4,8 @@ export type { ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, ProviderDiscoveryContext, + ProviderPrepareDynamicModelContext, + ProviderRuntimeModel, } from "../plugins/types.js"; export { @@ -14,5 +16,6 @@ export { export { configureLmstudioNonInteractive, discoverLmstudioProvider, + prepareLmstudioDynamicModels, promptAndConfigureLmstudioInteractive, } from "../commands/lmstudio-setup.js"; From fd8a999834f0467e13aebfb9be62653946b6709b Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 18:30:09 -0400 Subject: [PATCH 11/14] remove unnecessary code --- src/commands/doctor-memory-search.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/commands/doctor-memory-search.ts b/src/commands/doctor-memory-search.ts index 0a7512935591..4c50afe791a5 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -119,9 +119,6 @@ export async function noteMemorySearchHealth( ); return; } - if (!providerRequiresApiKey(resolved.provider)) { - return; - } // Remote provider — check for API key if (hasRemoteApiKey || (await hasApiKeyForProvider(resolved.provider, cfg, agentDir))) { return; @@ -253,14 +250,6 @@ async function hasApiKeyForProvider( } } -function providerRequiresApiKey( - provider: "openai" | "local" | "gemini" | "voyage" | "mistral" | "lmstudio" | "ollama", -): boolean { - // Local LM Studio can legitimately run without auth, so doctor should not - // warn purely because it cannot resolve a token from config. - return provider !== "lmstudio"; -} - function providerEnvVar(provider: string): string { switch (provider) { case "openai": From 7b01405dcb3ee7640011012bf399948dc0fde827 Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 18:34:07 -0400 Subject: [PATCH 12/14] Cleanup --- src/memory/manager.mistral-provider.test.ts | 16 ---------------- src/plugin-sdk/subpaths.test.ts | 1 - 2 files changed, 17 deletions(-) diff --git a/src/memory/manager.mistral-provider.test.ts b/src/memory/manager.mistral-provider.test.ts index 812a481b22eb..ca6d3c1e43ba 100644 --- a/src/memory/manager.mistral-provider.test.ts +++ b/src/memory/manager.mistral-provider.test.ts @@ -49,11 +49,6 @@ function buildConfig(params: { indexPath: string; provider: "openai" | "mistral" | "lmstudio"; fallback?: "none" | "mistral" | "ollama" | "lmstudio"; - remote?: { - baseUrl?: string; - apiKey?: string; - headers?: Record; - }; }): OpenClawConfig { return { agents: { @@ -62,7 +57,6 @@ function buildConfig(params: { memorySearch: { provider: params.provider, model: params.provider === "mistral" ? "mistral/mistral-embed" : "text-embedding-3-small", - remote: params.remote, fallback: params.fallback ?? "none", store: { path: params.indexPath, vector: { enabled: false } }, sync: { watch: false, onSessionStart: false, onSearch: false }, @@ -251,11 +245,6 @@ describe("memory manager mistral provider wiring", () => { indexPath, provider: "openai", fallback: "lmstudio", - remote: { - baseUrl: "https://openai-proxy.example/v1", - apiKey: "proxy-key", - headers: { "X-Proxy-Auth": "proxy-header" }, - }, }); const result = await getMemorySearchManager({ cfg, agentId: "main" }); if (!result.manager) { @@ -275,11 +264,6 @@ describe("memory manager mistral provider wiring", () => { | { provider?: string; model?: string; - remote?: { - baseUrl?: string; - apiKey?: string; - headers?: Record; - }; } | undefined; expect(fallbackCall?.provider).toBe("lmstudio"); diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index b73e5d4d84f3..d8183d0eaf35 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -564,7 +564,6 @@ describe("plugin-sdk subpath exports", () => { expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); - expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); expectTypeOf().toMatchTypeOf(); From c27578ad569c24714f7daf8626991074978f8c8b Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 19:01:37 -0400 Subject: [PATCH 13/14] Fix test --- src/memory/embeddings-lmstudio.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/memory/embeddings-lmstudio.test.ts b/src/memory/embeddings-lmstudio.test.ts index 21600e62898b..e93484582775 100644 --- a/src/memory/embeddings-lmstudio.test.ts +++ b/src/memory/embeddings-lmstudio.test.ts @@ -284,7 +284,10 @@ describe("embeddings-lmstudio", () => { settled = true; }); - await Promise.resolve(); + await vi.waitFor(() => { + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + expect(resolveWarmup).toBeTypeOf("function"); + }); expect(settled).toBe(false); resolveWarmup?.(); From d6737d4c088abea08ff308f147332e785be659bc Mon Sep 17 00:00:00 2001 From: rugvedS07 Date: Mon, 23 Mar 2026 20:23:05 -0400 Subject: [PATCH 14/14] revert --- docs/concepts/model-providers.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index b9b9a4104816..b8bc0d0d26eb 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -569,7 +569,7 @@ Then set a model (replace with one of the IDs returned by `/v1/models`): See [/providers/sglang](/providers/sglang) for details. -### Local proxies (vLLM, LiteLLM, etc.) +### Local proxies (LM Studio, vLLM, LiteLLM, etc.) Example (OpenAI‑compatible): @@ -577,15 +577,15 @@ Example (OpenAI‑compatible): { agents: { defaults: { - model: { primary: "localproxy/minimax-m2.5-gs32" }, - models: { "localproxy/minimax-m2.5-gs32": { alias: "Minimax" } }, + model: { primary: "lmstudio/minimax-m2.5-gs32" }, + models: { "lmstudio/minimax-m2.5-gs32": { alias: "Minimax" } }, }, }, models: { providers: { - localproxy: { + lmstudio: { baseUrl: "http://localhost:1234/v1", - apiKey: "LOCAL_PROXY_API_KEY", + apiKey: "LM_API_TOKEN", api: "openai-completions", models: [ {