diff --git a/.github/labeler.yml b/.github/labeler.yml index b7efd8d401a..d697bcb96f1 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -293,6 +293,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/CHANGELOG.md b/CHANGELOG.md index 3314c8d0991..6f1f09494a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ Docs: https://docs.openclaw.ai ### Changes - QA/lab: add Convex-backed pooled Telegram credential leasing plus `openclaw qa credentials` admin commands and broker setup docs. (#65596) Thanks @joshavant. +- Memory/Active Memory: add a new optional Active Memory plugin that gives OpenClaw a dedicated memory sub-agent right before the main reply, so ongoing chats can automatically pull in relevant preferences, context, and past details without making users remember to manually say "remember this" or "search memory" first. Includes configurable message/recent/full context modes, live `/verbose` inspection, advanced prompt/thinking overrides for tuning, and opt-in transcript persistence for debugging. Docs: https://docs.openclaw.ai/concepts/active-memory. (#63286) Thanks @Takhoffman. +- macOS/Talk: add an experimental local MLX speech provider for Talk Mode, with explicit provider selection, local utterance playback, interruption handling, and system-voice fallback. (#63539) Thanks @ImLukeF. +- CLI/exec policy: add a local `openclaw exec-policy` command with `show`, `preset`, and `set` subcommands for synchronizing requested `tools.exec.*` config with the local exec approvals file, plus follow-up hardening for node-host rejection, rollback safety, and sync conflict detection. (#64050) +- Gateway: add a `commands.list` RPC so remote gateway clients can discover runtime-native, text, skill, and plugin commands with surface-aware naming and serialized argument metadata. (#62656) Thanks @samzong. +- Models/providers: add per-provider `models.providers.*.request.allowPrivateNetwork` for trusted self-hosted OpenAI-compatible endpoints, keep the opt-in scoped to model request surfaces, and refresh cached WebSocket managers when request transport overrides change. (#63671) Thanks @qas. +- QA/testing: add a `--runner multipass` lane for `openclaw qa suite` so repo-backed QA scenarios can run inside a disposable Linux VM and write back the usual report, summary, and VM logs. (#63426) Thanks @shakkernerd. +- Docs i18n: chunk raw doc translation, reject truncated tagged outputs, avoid ambiguous body-only wrapper unwrapping, and recover from terminated Pi translation sessions without changing the default `openai/gpt-5.4` path. (#62969, #63808) Thanks @hxy91819. +- Control UI/dreaming: simplify the Scene and Diary surfaces, preserve unknown phase state for partial status payloads, and stabilize waiting-entry recency ordering so Dreaming status and review lists stay clear and deterministic. (#64035) Thanks @davemorin. +- Gateway: split startup and runtime seams so gateway lifecycle sequencing, reload state, and shutdown behavior stay easier to maintain without changing observed behavior. (#63975) Thanks @gumadeiras. +- Matrix/partial streaming: add MSC4357 live markers to draft preview sends and edits so supporting Matrix clients can render a live/typewriter animation and stop it when the final edit lands. (#63513) Thanks @TigerInYourDream. +- QA/Telegram: add a live `openclaw qa telegram` lane for private-group bot-to-bot checks, harden its artifact handling, and preserve native Telegram command reply threading for QA verification. (#64303) Thanks @obviyus. +- Models/Codex: add the bundled Codex provider and plugin-owned app-server harness so `codex/gpt-*` models use Codex-managed auth, native threads, model discovery, and compaction while `openai/gpt-*` stays on the normal OpenAI provider path. (#64298) Thanks @steipete. +- Models/providers: add a bundled LM Studio provider with onboarding, runtime model discovery, stream preload support, and memory-search embeddings for local/self-hosted OpenAI-compatible models. (#53248) Thanks @rugvedS07. ### Fixes diff --git a/docs/.generated/config-baseline.sha256 b/docs/.generated/config-baseline.sha256 index 84f578e0a74..66cbf11427c 100644 --- a/docs/.generated/config-baseline.sha256 +++ b/docs/.generated/config-baseline.sha256 @@ -1,4 +1,4 @@ -5f7ad1520f965f8b4b59b8f3e9733757d4b996ea5dfa40aca279dceeafb8aed7 config-baseline.json -9bf857e53f27d22eb4d8b22e6407e31c260c797047fdca07b5d95498a712662c config-baseline.core.json -3bb312dc9c39a374ca92613abf21606c25dc571287a3941dac71ff57b2b5c519 config-baseline.channel.json -aa4b1d3d04ed9f9feea73c8fca36c48a54749853e07fadfca54773171b2ef4ff config-baseline.plugin.json +8ae6f2aaa659fa6008b05deb09240c1d261830b151b15664dea9834f3b99c4ed config-baseline.json +d5f53e95eec6332d59889858d6898dddd8a73a5e4cabe22fc49d893a8e15d6a3 config-baseline.core.json +e1f94346a8507ce3dec763b598e79f3bb89ff2e33189ce977cc87d3b05e71c1d config-baseline.channel.json +2aaeb7a54022481b17ee2b460bce08f4933f1f5301f17cdb8a513cef8a15f667 config-baseline.plugin.json diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index 3536fd6ad61..13f54783420 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -ec0d47ca6df1d840719e6692a43cd2187603dc690fb0e8887fde760a4273b1c8 plugin-sdk-api-baseline.json -c0fc79136e9e90978feb613dc100ef17144cfa1c8451612f8e9a0583f7b7d902 plugin-sdk-api-baseline.jsonl +4fcfbafe5aadb6d1f170de50f0897ac35c13a5a5bf425a893d5ff94fae3a6c5f plugin-sdk-api-baseline.json +994b6e32f8f48c7f16b581e9533e1f2a5b03ce8fa0cce75a2ea0d4543a275f7a plugin-sdk-api-baseline.jsonl diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md index 667380003fc..d0617f0b84b 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -43,6 +43,17 @@ openclaw onboard --non-interactive \ `--custom-api-key` is optional in non-interactive mode. If omitted, onboarding checks `CUSTOM_API_KEY`. +LM Studio also supports a provider-specific key flag in non-interactive mode: + +```bash +openclaw onboard --non-interactive \ + --auth-choice lmstudio \ + --custom-base-url "http://localhost:1234/v1" \ + --custom-model-id "qwen/qwen3.5-9b" \ + --lmstudio-api-key "$LM_API_TOKEN" \ + --accept-risk +``` + Non-interactive Ollama: ```bash diff --git a/docs/concepts/model-providers.md b/docs/concepts/model-providers.md index 6437fe0c83c..0eb87f22ba9 100644 --- a/docs/concepts/model-providers.md +++ b/docs/concepts/model-providers.md @@ -672,6 +672,28 @@ Plugin-owned capability split: - Image understanding is plugin-owned `MiniMax-VL-01` on both MiniMax auth paths - Web search stays on provider id `minimax` +### LM Studio + +LM Studio ships as a bundled provider plugin which uses the native API: + +- Provider: `lmstudio` +- Auth: `LM_API_TOKEN` +- 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: @@ -770,7 +792,7 @@ Example (OpenAI‑compatible): providers: { lmstudio: { baseUrl: "http://localhost:1234/v1", - apiKey: "LMSTUDIO_KEY", + apiKey: "${LM_API_TOKEN}", api: "openai-completions", models: [ { diff --git a/docs/docs.json b/docs/docs.json index 77367c36d2c..c67007c80ad 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -1264,6 +1264,7 @@ "providers/inferrs", "providers/kilocode", "providers/litellm", + "providers/lmstudio", "providers/minimax", "providers/mistral", "providers/moonshot", diff --git a/docs/gateway/local-models.md b/docs/gateway/local-models.md index 38739afe051..d7bd15bfe85 100644 --- a/docs/gateway/local-models.md +++ b/docs/gateway/local-models.md @@ -11,7 +11,7 @@ title: "Local Models" Local is doable, but OpenClaw expects large context + strong defenses against prompt injection. Small cards truncate context and leak safety. Aim high: **≥2 maxed-out Mac Studios or equivalent GPU rig (~$30k+)**. A single **24 GB** GPU works only for lighter prompts with higher latency. Use the **largest / full-size model variant you can run**; aggressively quantized or “small” checkpoints raise prompt-injection risk (see [Security](/gateway/security)). -If you want the lowest-friction local setup, start with [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers. +If you want the lowest-friction local setup, start with [LM Studio](/providers/lmstudio) or [Ollama](/providers/ollama) and `openclaw onboard`. This page is the opinionated guide for higher-end local stacks and custom OpenAI-compatible local servers. ## Recommended: LM Studio + large local model (Responses API) diff --git a/docs/providers/index.md b/docs/providers/index.md index d5cadd595ed..f96cd6b6e2f 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -45,6 +45,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi - [inferrs (local models)](/providers/inferrs) - [Kilocode](/providers/kilocode) - [LiteLLM (unified gateway)](/providers/litellm) +- [LM Studio (local models)](/providers/lmstudio) - [MiniMax](/providers/minimax) - [Mistral](/providers/mistral) - [Moonshot AI (Kimi + Kimi Coding)](/providers/moonshot) diff --git a/docs/providers/lmstudio.md b/docs/providers/lmstudio.md new file mode 100644 index 00000000000..d5f18884915 --- /dev/null +++ b/docs/providers/lmstudio.md @@ -0,0 +1,159 @@ +--- +summary: "Run OpenClaw with LM Studio" +read_when: + - You want to run OpenClaw with open source models via LM Studio + - You want to set up and configure LM Studio +title: "LM Studio" +--- + +# LM Studio + +LM Studio is a friendly yet powerful app for running open-weight models on your own hardware. It lets you run llama.cpp (GGUF) or MLX models (Apple Silicon). Comes in a GUI package or headless daemon (`llmster`). For product and setup docs, see [lmstudio.ai](https://lmstudio.ai/). + +## Quick start + +1. Install LM Studio (desktop) or `llmster` (headless), then start the local server: + +```bash +curl -fsSL https://lmstudio.ai/install.sh | bash +``` + +2. Start the server + +Make sure you either start the desktop app or run the daemon using the following command: + +```bash +lms daemon up +``` + +```bash +lms server start --port 1234 +``` + +If you are using the app, make sure you have JIT enabled for a smooth experience. Learn more in the [LM Studio JIT and TTL guide](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 LM Studio authentication is disabled, use any non-empty token 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 +``` + +Or specify base URL or model with API key: + +```bash +openclaw onboard \ + --non-interactive \ + --accept-risk \ + --auth-choice lmstudio \ + --custom-base-url http://localhost:1234/v1 \ + --lmstudio-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. + +Non-interactive onboarding requires `--lmstudio-api-key` (or `LM_API_TOKEN` in env). +For unauthenticated LM Studio servers, any non-empty token value works. + +`--custom-api-key` remains supported for compatibility, but `--lmstudio-api-key` is preferred for LM Studio. + +This writes `models.providers.lmstudio`, sets the default model to +`lmstudio/`, and writes the `lmstudio:default` auth profile. + +Interactive setup can prompt for an optional preferred load context length and applies it across the discovered LM Studio models it saves into config. + +## 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` (for unauthenticated servers, any non-empty token value works): + +```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 token value for `LM_API_TOKEN`. + +### Just-in-time model loading + +LM Studio supports just-in-time (JIT) model loading, where models are loaded on first request. Make sure you have this enabled to avoid 'Model not loaded' errors. diff --git a/docs/reference/api-usage-costs.md b/docs/reference/api-usage-costs.md index 9fd4cfcfede..03803eaaefd 100644 --- a/docs/reference/api-usage-costs.md +++ b/docs/reference/api-usage-costs.md @@ -113,6 +113,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 00000000000..1cd1db344be --- /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/api.ts b/extensions/lmstudio/api.ts new file mode 100644 index 00000000000..e21f8a1f806 --- /dev/null +++ b/extensions/lmstudio/api.ts @@ -0,0 +1 @@ +export * from "./src/api.js"; diff --git a/extensions/lmstudio/index.test.ts b/extensions/lmstudio/index.test.ts new file mode 100644 index 00000000000..ed6bea91561 --- /dev/null +++ b/extensions/lmstudio/index.test.ts @@ -0,0 +1,194 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/plugin-entry"; +import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { capturePluginRegistration } from "openclaw/plugin-sdk/testing"; +import { describe, expect, it } from "vitest"; +import plugin from "./index.js"; +import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "./src/defaults.js"; + +function registerProvider() { + const captured = capturePluginRegistration(plugin); + const provider = captured.providers[0]; + expect(provider?.id).toBe("lmstudio"); + return provider; +} + +function createRemoteProviderConfig(overrides?: Partial): ModelProviderConfig { + return { + api: "openai-completions", + baseUrl: "http://lmstudio.internal:1234/v1", + models: [ + { + id: "qwen/qwen3.5-9b", + name: "Qwen 3.5 9B", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 131072, + maxTokens: 8192, + }, + ], + ...overrides, + }; +} + +describe("lmstudio plugin", () => { + it("canonicalizes base URLs during provider normalization", () => { + const provider = registerProvider(); + + expect( + provider?.normalizeConfig?.({ + provider: "lmstudio", + providerConfig: createRemoteProviderConfig({ + baseUrl: "http://localhost:1234/api/v1/", + }), + }), + ).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + }); + }); + + it("synthesizes placeholder auth for configured lmstudio models without API key auth", () => { + const provider = registerProvider(); + + expect( + provider?.resolveSyntheticAuth?.({ + provider: "lmstudio", + config: {}, + providerConfig: createRemoteProviderConfig({ + headers: { + "X-Proxy-Auth": "proxy-token", + }, + }), + }), + ).toEqual({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key", + }); + }); + + it("still synthesizes placeholder auth when explicit api-key auth has no key", () => { + const provider = registerProvider(); + + expect( + provider?.resolveSyntheticAuth?.({ + provider: "lmstudio", + config: {}, + providerConfig: createRemoteProviderConfig({ + auth: "api-key", + }), + }), + ).toEqual({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key", + }); + }); + + it("does not synthesize placeholder auth when Authorization header is configured", () => { + const provider = registerProvider(); + + expect( + provider?.resolveSyntheticAuth?.({ + provider: "lmstudio", + config: {}, + providerConfig: createRemoteProviderConfig({ + headers: { + Authorization: "Bearer proxy-token", + }, + }), + }), + ).toBeUndefined(); + }); + + it("defers stored lmstudio-local profile auth so real credentials can win", () => { + const provider = registerProvider(); + + expect( + provider?.shouldDeferSyntheticProfileAuth?.({ + provider: "lmstudio", + config: {}, + providerConfig: createRemoteProviderConfig(), + resolvedApiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + }), + ).toBe(true); + + expect( + provider?.shouldDeferSyntheticProfileAuth?.({ + provider: "lmstudio", + config: {}, + providerConfig: createRemoteProviderConfig(), + resolvedApiKey: CUSTOM_LOCAL_AUTH_MARKER, + }), + ).toBe(true); + + expect( + provider?.shouldDeferSyntheticProfileAuth?.({ + provider: "lmstudio", + config: {}, + providerConfig: createRemoteProviderConfig(), + resolvedApiKey: "lmstudio-real-key", + }), + ).toBe(false); + }); + + it("augments the catalog with configured lmstudio models", () => { + const provider = registerProvider(); + const config = { + models: { + providers: { + lmstudio: { + models: [ + { + id: "qwen3-8b-instruct", + name: "Qwen 3 8B Instruct", + contextWindow: 32768, + contextTokens: 8192, + reasoning: true, + input: ["text", "image"], + }, + { + id: "phi-4", + }, + { + id: " ", + name: "ignored", + }, + ], + }, + }, + }, + } as unknown as OpenClawConfig; + + expect( + provider?.augmentModelCatalog?.({ + config, + agentDir: "/tmp/openclaw", + env: {}, + entries: [], + }), + ).toEqual([ + { + provider: "lmstudio", + id: "qwen3-8b-instruct", + name: "Qwen 3 8B Instruct", + compat: { supportsUsageInStreaming: true }, + contextWindow: 32768, + contextTokens: 8192, + reasoning: true, + input: ["text", "image"], + }, + { + provider: "lmstudio", + id: "phi-4", + name: "phi-4", + compat: { supportsUsageInStreaming: true }, + contextWindow: undefined, + contextTokens: undefined, + reasoning: undefined, + input: undefined, + }, + ]); + }); +}); diff --git a/extensions/lmstudio/index.ts b/extensions/lmstudio/index.ts new file mode 100644 index 00000000000..e47d9bdef9b --- /dev/null +++ b/extensions/lmstudio/index.ts @@ -0,0 +1,134 @@ +import { + definePluginEntry, + OpenClawConfig, + type OpenClawPluginApi, + type ProviderAuthContext, + type ProviderAuthMethodNonInteractiveContext, + type ProviderAuthResult, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/plugin-entry"; +import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth"; +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + LMSTUDIO_PROVIDER_LABEL, +} from "./src/defaults.js"; +import { + normalizeLmstudioConfiguredCatalogEntries, + normalizeLmstudioProviderConfig, +} from "./src/models.js"; +import { shouldUseLmstudioSyntheticAuth } from "./src/provider-auth.js"; +import { wrapLmstudioInferencePreload } from "./src/stream.js"; + +const PROVIDER_ID = "lmstudio"; +// Intentional: dynamic models are cached per LM Studio endpoint (`baseUrl`) only. +const cachedDynamicModels = new Map(); + +function resolveLmstudioAugmentedCatalogEntries(config: OpenClawConfig | undefined) { + if (!config) { + return []; + } + return normalizeLmstudioConfiguredCatalogEntries(config.models?.providers?.lmstudio?.models).map( + (entry) => ({ + provider: PROVIDER_ID, + id: entry.id, + name: entry.name ?? entry.id, + compat: { supportsUsageInStreaming: true }, + contextWindow: entry.contextWindow, + contextTokens: entry.contextTokens, + reasoning: entry.reasoning, + input: entry.input, + }), + ); +} + +/** Lazily loads setup helpers so provider wiring stays lightweight at startup. */ +async function loadProviderSetup() { + return await import("./api.js"); +} + +export default definePluginEntry({ + id: PROVIDER_ID, + name: "LM Studio Provider", + description: "Bundled LM Studio provider plugin", + register(api: OpenClawPluginApi) { + api.registerProvider({ + id: PROVIDER_ID, + label: "LM Studio", + docsPath: "/providers/lmstudio", + envVars: [LMSTUDIO_DEFAULT_API_KEY_ENV_VAR], + auth: [ + { + id: "custom", + label: LMSTUDIO_PROVIDER_LABEL, + hint: "Local/self-hosted LM Studio server", + kind: "custom", + run: async (ctx: ProviderAuthContext): Promise => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.promptAndConfigureLmstudioInteractive({ + config: ctx.config, + prompter: ctx.prompter, + secretInputMode: ctx.secretInputMode, + allowSecretRefPrompt: ctx.allowSecretRefPrompt, + }); + }, + runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.configureLmstudioNonInteractive(ctx); + }, + }, + ], + discovery: { + // Run after early providers so local LM Studio detection does not dominate resolution. + order: "late", + run: async (ctx) => { + const providerSetup = await loadProviderSetup(); + return await providerSetup.discoverLmstudioProvider(ctx); + }, + }, + resolveSyntheticAuth: ({ providerConfig }) => { + if (!shouldUseLmstudioSyntheticAuth(providerConfig)) { + return undefined; + } + return { + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key" as const, + }; + }, + shouldDeferSyntheticProfileAuth: ({ resolvedApiKey }) => + resolvedApiKey?.trim() === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER || + resolvedApiKey?.trim() === CUSTOM_LOCAL_AUTH_MARKER, + normalizeConfig: ({ providerConfig }) => normalizeLmstudioProviderConfig(providerConfig), + 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), + augmentModelCatalog: (ctx) => resolveLmstudioAugmentedCatalogEntries(ctx.config), + wrapStreamFn: wrapLmstudioInferencePreload, + wizard: { + setup: { + choiceId: PROVIDER_ID, + choiceLabel: "LM Studio", + choiceHint: "Local/self-hosted LM Studio server", + groupId: PROVIDER_ID, + groupLabel: "LM Studio", + groupHint: "Self-hosted open-weight models", + methodId: "custom", + }, + modelPicker: { + label: "LM Studio (custom)", + hint: "Detect models from LM Studio /api/v1/models", + methodId: "custom", + }, + }, + }); + }, +}); diff --git a/extensions/lmstudio/openclaw.plugin.json b/extensions/lmstudio/openclaw.plugin.json new file mode 100644 index 00000000000..0fc035ce76b --- /dev/null +++ b/extensions/lmstudio/openclaw.plugin.json @@ -0,0 +1,29 @@ +{ + "id": "lmstudio", + "enabledByDefault": true, + "providers": ["lmstudio"], + "providerAuthEnvVars": { + "lmstudio": ["LM_API_TOKEN"] + }, + "providerAuthChoices": [ + { + "provider": "lmstudio", + "method": "custom", + "choiceId": "lmstudio", + "choiceLabel": "LM Studio", + "choiceHint": "Local/self-hosted LM Studio server", + "optionKey": "lmstudioApiKey", + "cliFlag": "--lmstudio-api-key", + "cliOption": "--lmstudio-api-key ", + "cliDescription": "LM Studio API key", + "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 00000000000..a713481d76e --- /dev/null +++ b/extensions/lmstudio/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openclaw/lmstudio-provider", + "version": "2026.4.6", + "private": true, + "description": "OpenClaw LM Studio provider plugin", + "type": "module", + "openclaw": { + "extensions": [ + "./index.ts" + ] + } +} diff --git a/extensions/lmstudio/plugin-registration.contract.test.ts b/extensions/lmstudio/plugin-registration.contract.test.ts new file mode 100644 index 00000000000..4ece8673dfa --- /dev/null +++ b/extensions/lmstudio/plugin-registration.contract.test.ts @@ -0,0 +1,6 @@ +import { describePluginRegistrationContract } from "../../test/helpers/plugins/plugin-registration-contract.js"; + +describePluginRegistrationContract({ + pluginId: "lmstudio", + providerIds: ["lmstudio"], +}); diff --git a/extensions/lmstudio/runtime-api.ts b/extensions/lmstudio/runtime-api.ts new file mode 100644 index 00000000000..58e47d6ea77 --- /dev/null +++ b/extensions/lmstudio/runtime-api.ts @@ -0,0 +1,35 @@ +export { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_DEFAULT_BASE_URL, + LMSTUDIO_DEFAULT_EMBEDDING_MODEL, + LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + LMSTUDIO_DEFAULT_MODEL_ID, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + LMSTUDIO_MODEL_PLACEHOLDER, + LMSTUDIO_PROVIDER_ID, + LMSTUDIO_PROVIDER_LABEL, +} from "./src/defaults.js"; +export { + discoverLmstudioModels, + ensureLmstudioModelLoaded, + fetchLmstudioModels, +} from "./src/models.fetch.js"; +export { + mapLmstudioWireEntry, + mapLmstudioWireModelsToConfig, + normalizeLmstudioProviderConfig, + resolveLoadedContextWindow, + resolveLmstudioInferenceBase, + resolveLmstudioReasoningCapability, + resolveLmstudioServerBase, + type LmstudioModelBase, + type LmstudioModelWire, +} from "./src/models.js"; +export { + buildLmstudioAuthHeaders, + resolveLmstudioConfiguredApiKey, + resolveLmstudioProviderHeaders, + resolveLmstudioRequestContext, + resolveLmstudioRuntimeApiKey, +} from "./src/runtime.js"; diff --git a/extensions/lmstudio/src/api.ts b/extensions/lmstudio/src/api.ts new file mode 100644 index 00000000000..352b66c00cd --- /dev/null +++ b/extensions/lmstudio/src/api.ts @@ -0,0 +1,4 @@ +export * from "./defaults.js"; +export * from "./models.js"; +export * from "./runtime.js"; +export * from "./setup.js"; diff --git a/extensions/lmstudio/src/defaults.ts b/extensions/lmstudio/src/defaults.ts new file mode 100644 index 00000000000..79bdbfc1da3 --- /dev/null +++ b/extensions/lmstudio/src/defaults.ts @@ -0,0 +1,12 @@ +/** 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"; +export const LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER = "lmstudio-local"; +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/extensions/lmstudio/src/models.fetch.ts b/extensions/lmstudio/src/models.fetch.ts new file mode 100644 index 00000000000..a2397665f01 --- /dev/null +++ b/extensions/lmstudio/src/models.fetch.ts @@ -0,0 +1,265 @@ +import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { SELF_HOSTED_DEFAULT_COST } from "openclaw/plugin-sdk/provider-setup"; +import { fetchWithSsrFGuard, type SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; +import { + buildLmstudioModelName, + mapLmstudioWireEntry, + resolveLmstudioServerBase, + resolveLoadedContextWindow, + type LmstudioModelWire, +} from "./models.js"; +import { buildLmstudioAuthHeaders } from "./runtime.js"; + +const log = createSubsystemLogger("extensions/lmstudio/models"); + +type LmstudioLoadResponse = { + status?: string; +}; + +export type FetchLmstudioModelsResult = { + reachable: boolean; + status?: number; + models: LmstudioModelWire[]; + error?: unknown; +}; + +type LmstudioModelsResponseWire = { + models?: LmstudioModelWire[]; +}; + +type DiscoverLmstudioModelsParams = { + baseUrl: string; + apiKey: string; + headers?: Record; + quiet: boolean; + /** Injectable fetch implementation; defaults to the global fetch. */ + fetchImpl?: typeof fetch; +}; + +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, + }; + } +} + +/** 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: SELF_HOSTED_DEFAULT_COST, + compat: { supportsUsageInStreaming: true }, + contextWindow: base.contextWindow, + contextTokens: base.contextTokens, + 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; + requestedContextLength?: number; + 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); + const loadedContextWindow = matchingModel ? resolveLoadedContextWindow(matchingModel) : null; + 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 = + params.requestedContextLength !== undefined && + Number.isFinite(params.requestedContextLength) && + params.requestedContextLength > 0 + ? Math.floor(params.requestedContextLength) + : null; + const contextLengthForLoad = + advertisedContextLimit === null + ? (requestedContextLength ?? LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH) + : Math.min( + requestedContextLength ?? LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + advertisedContextLimit, + ); + if (loadedContextWindow !== null && loadedContextWindow >= contextLengthForLoad) { + return; + } + + 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: contextLengthForLoad, + }), + }, + timeoutMs, + fetchImpl: params.fetchImpl, + ssrfPolicy: params.ssrfPolicy, + auditContext: "lmstudio-model-load", + }); + try { + if (!response.ok) { + const body = await response.text(); + throw new Error(`LM Studio model load failed (${response.status})${body ? `: ${body}` : ""}`); + } + const payload = (await response.json()) as LmstudioLoadResponse; + if (typeof payload.status === "string" && payload.status.toLowerCase() !== "loaded") { + throw new Error(`LM Studio model load returned unexpected status: ${payload.status}`); + } + } finally { + await release(); + } +} diff --git a/extensions/lmstudio/src/models.test.ts b/extensions/lmstudio/src/models.test.ts new file mode 100644 index 00000000000..ec565dca355 --- /dev/null +++ b/extensions/lmstudio/src/models.test.ts @@ -0,0 +1,381 @@ +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "openclaw/plugin-sdk/provider-setup"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; +import { discoverLmstudioModels, ensureLmstudioModelLoaded } from "./models.fetch.js"; +import { + resolveLmstudioInferenceBase, + resolveLmstudioReasoningCapability, + resolveLmstudioServerBase, +} from "./models.js"; + +const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args), + }; +}); + +describe("lmstudio-models", () => { + const asFetch = (mock: T) => mock as unknown as typeof fetch; + const parseJsonRequestBody = (init: RequestInit | undefined): T => { + if (typeof init?.body !== "string") { + throw new Error("Expected request body to be a JSON string"); + } + return JSON.parse(init.body) as T; + }; + + afterEach(() => { + fetchWithSsrFGuardMock.mockReset(); + vi.unstubAllGlobals(); + }); + + it("normalizes LM Studio base URLs", () => { + expect(resolveLmstudioServerBase()).toBe("http://localhost:1234"); + expect(resolveLmstudioInferenceBase()).toBe("http://localhost:1234/v1"); + expect(resolveLmstudioServerBase("http://localhost:1234/api/v1")).toBe("http://localhost:1234"); + expect(resolveLmstudioInferenceBase("http://localhost:1234/api/v1")).toBe( + "http://localhost:1234/v1", + ); + }); + + it("resolves reasoning capability for supported and unsupported options", () => { + expect(resolveLmstudioReasoningCapability({ capabilities: undefined })).toBe(false); + expect( + resolveLmstudioReasoningCapability({ + capabilities: { + reasoning: { + allowed_options: ["low", "medium", "high"], + default: "low", + }, + }, + }), + ).toBe(true); + expect( + resolveLmstudioReasoningCapability({ + capabilities: { + reasoning: { + allowed_options: ["off"], + default: "off", + }, + }, + }), + ).toBe(false); + }); + + it("discovers llm models and maps metadata", async () => { + const fetchMock = vi.fn(async (_url: string | URL) => ({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + display_name: "Qwen3 8B", + max_context_length: 262144, + format: "mlx", + capabilities: { + vision: true, + trained_for_tool_use: true, + reasoning: { + allowed_options: ["off", "on"], + default: "on", + }, + }, + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + { + type: "llm", + key: "deepseek-r1", + }, + { + type: "embedding", + key: "text-embedding-nomic-embed-text-v1.5", + }, + { + type: "llm", + key: " ", + }, + ], + }), + })); + + const models = await discoverLmstudioModels({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lm-token", + quiet: false, + fetchImpl: asFetch(fetchMock), + }); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/api/v1/models", + expect.objectContaining({ + headers: { + Authorization: "Bearer lm-token", + }, + }), + ); + + expect(models).toHaveLength(2); + expect(models[0]).toEqual({ + id: "qwen3-8b-instruct", + name: "Qwen3 8B (MLX, vision, tool-use, loaded)", + reasoning: true, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { supportsUsageInStreaming: true }, + contextWindow: 262144, + contextTokens: LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + 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 }, + compat: { supportsUsageInStreaming: true }, + contextWindow: SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + contextTokens: LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + maxTokens: SELF_HOSTED_DEFAULT_MAX_TOKENS, + }); + }); + + it("skips model load when already loaded", async () => { + const fetchMock = vi.fn(async (_url: string | URL) => ({ + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + ], + }), + })); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const calledUrls = fetchMock.mock.calls.map((call) => String(call[0])); + expect(calledUrls).not.toContain("http://localhost:1234/api/v1/models/load"); + }); + + it("reloads model when requested context length exceeds the loaded window", 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: [{ id: "inst-1", config: { context_length: 4096 } }], + }, + ], + }), + }; + } + if (String(url).endsWith("/api/v1/models/load")) { + return { + ok: true, + json: async () => ({ status: "loaded" }), + requestInit: init, + }; + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + requestedContextLength: 8192, + }), + ).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] as RequestInit; + const loadBody = parseJsonRequestBody<{ context_length: number }>(loadInit); + expect(loadBody.context_length).toBe(8192); + }); + + it("reloads model to the clamped default target when already loaded below the default window", 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: [{ id: "inst-1", config: { context_length: 4096 } }], + }, + ], + }), + }; + } + if (String(url).endsWith("/api/v1/models/load")) { + return { + ok: true, + json: async () => ({ status: "loaded" }), + requestInit: init, + }; + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + 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] as RequestInit; + const loadBody = parseJsonRequestBody<{ context_length: number }>(loadInit); + expect(loadBody.context_length).toBe(32768); + }); + + it("loads model with clamped context length and merged headers", async () => { + const fetchMock = vi.fn(async (url: string | URL, init?: RequestInit) => { + if (String(url).endsWith("/api/v1/models")) { + return { + ok: true, + json: async () => ({ + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + max_context_length: 32768, + loaded_instances: [], + }, + ], + }), + }; + } + if (String(url).endsWith("/api/v1/models/load")) { + return { + ok: true, + json: async () => ({ status: "loaded" }), + requestInit: init, + }; + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lm-token", + headers: { + "X-Proxy-Auth": "required", + Authorization: "Bearer override", + }, + modelKey: " qwen3-8b-instruct ", + }), + ).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(2); + const loadCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/models/load")); + expect(loadCall).toBeDefined(); + expect(loadCall?.[1]).toMatchObject({ + method: "POST", + headers: { + "X-Proxy-Auth": "required", + Authorization: "Bearer lm-token", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model: "qwen3-8b-instruct", + context_length: 32768, + }), + }); + const loadInit = loadCall![1] as RequestInit; + const loadBody = parseJsonRequestBody<{ context_length: number }>(loadInit); + expect(loadBody.context_length).not.toBe(LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH); + }); + + it("uses requested context length when provided for model load", 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" }), + }; + } + throw new Error(`Unexpected fetch URL: ${String(url)}`); + }); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + requestedContextLength: 8192, + }), + ).resolves.toBeUndefined(); + + const loadCall = fetchMock.mock.calls.find((call) => String(call[0]).endsWith("/models/load")); + expect(loadCall).toBeDefined(); + const loadInit = loadCall?.[1] as unknown as RequestInit; + const loadBody = parseJsonRequestBody<{ context_length: number }>(loadInit); + expect(loadBody.context_length).toBe(8192); + }); + + it("throws when model discovery fails", async () => { + const fetchMock = vi.fn(async () => ({ + ok: false, + status: 401, + })); + vi.stubGlobal("fetch", asFetch(fetchMock)); + + await expect( + ensureLmstudioModelLoaded({ + baseUrl: "http://localhost:1234/v1", + modelKey: "qwen3-8b-instruct", + }), + ).rejects.toThrow("LM Studio model discovery failed (401)"); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/extensions/lmstudio/src/models.ts b/extensions/lmstudio/src/models.ts new file mode 100644 index 00000000000..d473a19694d --- /dev/null +++ b/extensions/lmstudio/src/models.ts @@ -0,0 +1,328 @@ +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + SELF_HOSTED_DEFAULT_COST, + SELF_HOSTED_DEFAULT_MAX_TOKENS, +} from "openclaw/plugin-sdk/provider-setup"; +import { LMSTUDIO_DEFAULT_BASE_URL, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH } from "./defaults.js"; + +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 LmstudioConfiguredCatalogEntry = { + id: string; + name?: string; + contextWindow?: number; + contextTokens?: number; + reasoning?: boolean; + input?: ("text" | "image" | "document")[]; +}; + +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`; +} + +/** Canonicalizes persisted LM Studio provider config to the inference base URL form. */ +export function normalizeLmstudioProviderConfig( + provider: ModelProviderConfig, +): ModelProviderConfig { + const configuredBaseUrl = typeof provider.baseUrl === "string" ? provider.baseUrl.trim() : ""; + if (!configuredBaseUrl) { + return provider; + } + const normalizedBaseUrl = resolveLmstudioInferenceBase(configuredBaseUrl); + return normalizedBaseUrl === provider.baseUrl + ? provider + : { ...provider, baseUrl: normalizedBaseUrl }; +} + +export function normalizeLmstudioConfiguredCatalogEntry( + entry: unknown, +): LmstudioConfiguredCatalogEntry | null { + if (!entry || typeof entry !== "object") { + return null; + } + const record = entry as Record; + if (typeof record.id !== "string" || record.id.trim().length === 0) { + return null; + } + const id = record.id.trim(); + const name = typeof record.name === "string" && record.name.trim().length > 0 ? record.name : id; + const contextWindow = + typeof record.contextWindow === "number" && record.contextWindow > 0 + ? record.contextWindow + : undefined; + const contextTokens = + typeof record.contextTokens === "number" && record.contextTokens > 0 + ? record.contextTokens + : undefined; + const reasoning = typeof record.reasoning === "boolean" ? record.reasoning : undefined; + const input = Array.isArray(record.input) + ? record.input.filter( + (item): item is "text" | "image" | "document" => + item === "text" || item === "image" || item === "document", + ) + : undefined; + return { + id, + name, + contextWindow, + contextTokens, + reasoning, + input: input && input.length > 0 ? input : undefined, + }; +} + +export function normalizeLmstudioConfiguredCatalogEntries( + models: unknown, +): LmstudioConfiguredCatalogEntry[] { + if (!Array.isArray(models)) { + return []; + } + return models + .map((entry) => normalizeLmstudioConfiguredCatalogEntry(entry)) + .filter((entry): entry is LmstudioConfiguredCatalogEntry => entry !== null); +} + +export 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; + contextTokens: 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 advertisedContextWindow = + entry.max_context_length !== undefined && + Number.isFinite(entry.max_context_length) && + entry.max_context_length > 0 + ? Math.floor(entry.max_context_length) + : null; + const contextWindow = advertisedContextWindow ?? SELF_HOSTED_DEFAULT_CONTEXT_WINDOW; + // Keep native/advertised context window metadata in catalog, but use a practical + // default target for model loading unless callers explicitly override it. + const contextTokens = Math.min(contextWindow, LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH); + 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, + contextTokens, + maxTokens: Math.max(1, Math.min(contextWindow, SELF_HOSTED_DEFAULT_MAX_TOKENS)), + }; +} + +/** + * Maps LM Studio wire models to config entries using plain display names. + * Use this for config persistence where runtime format/state tags are not needed. + * For runtime discovery with enriched names, use discoverLmstudioModels from models.fetch.ts. + */ +export function mapLmstudioWireModelsToConfig( + models: LmstudioModelWire[], +): ModelDefinitionConfig[] { + return models + .map((entry): ModelDefinitionConfig | null => { + 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, + contextTokens: base.contextTokens, + maxTokens: base.maxTokens, + }; + }) + .filter((entry): entry is ModelDefinitionConfig => entry !== null); +} diff --git a/extensions/lmstudio/src/provider-auth.ts b/extensions/lmstudio/src/provider-auth.ts new file mode 100644 index 00000000000..46737647e9e --- /dev/null +++ b/extensions/lmstudio/src/provider-auth.ts @@ -0,0 +1,59 @@ +import { + CUSTOM_LOCAL_AUTH_MARKER, + hasConfiguredSecretInput, + normalizeOptionalSecretInput, +} from "openclaw/plugin-sdk/provider-auth"; +import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "./defaults.js"; + +export function hasLmstudioAuthorizationHeader(headers: unknown): boolean { + if (!headers || typeof headers !== "object" || Array.isArray(headers)) { + return false; + } + for (const [headerName, headerValue] of Object.entries(headers)) { + if (headerName.trim().toLowerCase() !== "authorization") { + continue; + } + if (hasConfiguredSecretInput(headerValue)) { + return true; + } + } + return false; +} + +export function resolveLmstudioProviderAuthMode( + apiKey: ModelProviderConfig["apiKey"] | undefined, +): ModelProviderConfig["auth"] | undefined { + const normalized = normalizeOptionalSecretInput(apiKey); + if (normalized !== undefined) { + const trimmed = normalized.trim(); + if ( + !trimmed || + trimmed === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER || + trimmed === CUSTOM_LOCAL_AUTH_MARKER + ) { + return undefined; + } + return "api-key"; + } + return hasConfiguredSecretInput(apiKey) ? "api-key" : undefined; +} + +export function shouldUseLmstudioApiKeyPlaceholder(params: { + hasModels: boolean; + resolvedApiKey: ModelProviderConfig["apiKey"] | undefined; + hasAuthorizationHeader?: boolean; +}): boolean { + return params.hasModels && !params.resolvedApiKey && !params.hasAuthorizationHeader; +} + +export function shouldUseLmstudioSyntheticAuth( + providerConfig: ModelProviderConfig | undefined, +): boolean { + const hasModels = Array.isArray(providerConfig?.models) && providerConfig.models.length > 0; + return ( + hasModels && + !resolveLmstudioProviderAuthMode(providerConfig?.apiKey) && + !hasLmstudioAuthorizationHeader(providerConfig?.headers) + ); +} diff --git a/extensions/lmstudio/src/runtime.test.ts b/extensions/lmstudio/src/runtime.test.ts new file mode 100644 index 00000000000..f2d33aec012 --- /dev/null +++ b/extensions/lmstudio/src/runtime.test.ts @@ -0,0 +1,256 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; +import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER } from "./defaults.js"; +import { + buildLmstudioAuthHeaders, + resolveLmstudioConfiguredApiKey, + resolveLmstudioProviderHeaders, + resolveLmstudioRuntimeApiKey, +} from "./runtime.js"; + +const resolveApiKeyForProviderMock = vi.hoisted(() => vi.fn()); + +vi.mock("openclaw/plugin-sdk/provider-auth-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveApiKeyForProvider: (...args: unknown[]) => resolveApiKeyForProviderMock(...args), + }; +}); + +function buildLmstudioConfig(overrides?: { + apiKey?: unknown; + headers?: unknown; + auth?: "api-key"; +}): OpenClawConfig { + return { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + ...(overrides?.auth ? { auth: overrides.auth } : {}), + ...(overrides?.apiKey !== undefined ? { apiKey: overrides.apiKey } : {}), + ...(overrides?.headers !== undefined ? { headers: overrides.headers } : {}), + models: [], + }, + }, + }, + } as OpenClawConfig; +} + +describe("lmstudio-runtime", () => { + beforeEach(() => { + resolveApiKeyForProviderMock.mockReset(); + }); + + it("throws when runtime auth resolves to blank and no configured key exists", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ + apiKey: " ", + source: "profile:lmstudio:default", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + }), + ).rejects.toThrow(/LM Studio API key is required/i); + }); + + it("falls back to configured env marker key when profile resolution fails", async () => { + resolveApiKeyForProviderMock.mockRejectedValueOnce( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ + auth: "api-key", + apiKey: "${LM_API_TOKEN}", + }), + env: { + LM_API_TOKEN: "template-lmstudio-key", + }, + }), + ).resolves.toBe("template-lmstudio-key"); + }); + + it("accepts synthesized lmstudio-local for non-explicit auth mode", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig(), + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("accepts synthesized lmstudio-local for explicit api-key mode", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + }), + ).resolves.toBe(LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER); + }); + + it("accepts shared synthetic local marker for keyless runtime auth", async () => { + resolveApiKeyForProviderMock.mockResolvedValueOnce({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + source: "models.providers.lmstudio (synthetic local key)", + mode: "api-key", + }); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig(), + }), + ).resolves.toBe(CUSTOM_LOCAL_AUTH_MARKER); + }); + + it("allows header-only runtime auth when Authorization is configured", async () => { + resolveApiKeyForProviderMock.mockRejectedValueOnce( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ + headers: { + Authorization: "Bearer proxy-token", + }, + }), + }), + ).resolves.toBeUndefined(); + }); + + it("throws when explicit api-key mode cannot resolve any key", async () => { + resolveApiKeyForProviderMock.mockRejectedValue( + new Error('No API key found for provider "lmstudio". Auth store: /tmp/auth-profiles.json.'), + ); + + await expect( + resolveLmstudioRuntimeApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + }), + ).rejects.toThrow(/LM Studio API key is required/i); + + await expect( + resolveLmstudioConfiguredApiKey({ + config: buildLmstudioConfig({ auth: "api-key" }), + }), + ).resolves.toBeUndefined(); + }); + + it("resolves SecretRef api key and headers", async () => { + const headerRef = { + "X-Proxy-Auth": { + source: "env" as const, + provider: "default" as const, + id: "LMSTUDIO_PROXY_TOKEN", + }, + }; + await expect( + resolveLmstudioConfiguredApiKey({ + config: buildLmstudioConfig({ + apiKey: { + source: "env", + provider: "default", + id: "LM_API_TOKEN", + }, + }), + env: { + LM_API_TOKEN: "secretref-lmstudio-key", + }, + }), + ).resolves.toBe("secretref-lmstudio-key"); + + await expect( + resolveLmstudioProviderHeaders({ + config: buildLmstudioConfig({ headers: headerRef }), + env: { + LMSTUDIO_PROXY_TOKEN: "proxy-token", + }, + headers: headerRef, + }), + ).resolves.toEqual({ + "X-Proxy-Auth": "proxy-token", + }); + }); + + it("resolves env-template api keys from config", async () => { + await expect( + resolveLmstudioConfiguredApiKey({ + config: buildLmstudioConfig({ + apiKey: "${LM_API_TOKEN}", + }), + env: { + LM_API_TOKEN: "template-lmstudio-key", + }, + }), + ).resolves.toBe("template-lmstudio-key"); + }); + + it("throws a path-specific error when a SecretRef header cannot be resolved", async () => { + const headerRef = { + "X-Proxy-Auth": { + source: "env" as const, + provider: "default" as const, + id: "LMSTUDIO_PROXY_TOKEN", + }, + }; + await expect( + resolveLmstudioProviderHeaders({ + config: buildLmstudioConfig({ headers: headerRef }), + env: {}, + headers: headerRef, + }), + ).rejects.toThrow(/models\.providers\.lmstudio\.headers\.X-Proxy-Auth/i); + }); + + it("builds auth headers with key precedence and json support", () => { + expect(buildLmstudioAuthHeaders({})).toBeUndefined(); + expect(buildLmstudioAuthHeaders({ apiKey: " sk-test " })).toEqual({ + Authorization: "Bearer sk-test", + }); + expect(buildLmstudioAuthHeaders({ apiKey: " " })).toBeUndefined(); + expect( + buildLmstudioAuthHeaders({ apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER }), + ).toBeUndefined(); + expect( + buildLmstudioAuthHeaders({ + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + headers: { + Authorization: "Bearer proxy-token", + }, + }), + ).toEqual({ + Authorization: "Bearer proxy-token", + }); + expect( + buildLmstudioAuthHeaders({ + apiKey: "sk-new", + json: true, + headers: { + authorization: "Bearer sk-old", + "X-Proxy": "proxy-token", + }, + }), + ).toEqual({ + "Content-Type": "application/json", + "X-Proxy": "proxy-token", + Authorization: "Bearer sk-new", + }); + }); +}); diff --git a/extensions/lmstudio/src/runtime.ts b/extensions/lmstudio/src/runtime.ts new file mode 100644 index 00000000000..7cb5579a6a5 --- /dev/null +++ b/extensions/lmstudio/src/runtime.ts @@ -0,0 +1,237 @@ +import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/config-runtime"; +import { + CUSTOM_LOCAL_AUTH_MARKER, + isKnownEnvApiKeyMarker, + isNonSecretApiKeyMarker, + normalizeApiKeyConfig, + normalizeOptionalSecretInput, + type OpenClawConfig, +} from "openclaw/plugin-sdk/provider-auth"; +import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + LMSTUDIO_PROVIDER_ID, +} from "./defaults.js"; +import { hasLmstudioAuthorizationHeader } from "./provider-auth.js"; + +type LmstudioAuthHeadersParams = { + apiKey?: string; + json?: boolean; + headers?: Record; +}; + +export function buildLmstudioAuthHeaders( + params: LmstudioAuthHeadersParams, +): Record | undefined { + const headers: Record = { ...params.headers }; + // Runtime auth resolution is strict, but guard known non-secret markers here. + const apiKey = params.apiKey?.trim(); + const isSyntheticLocalKey = apiKey === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER; + if (apiKey && !isSyntheticLocalKey && !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; + path?: string; +}): Promise { + const providerConfig = params.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID]; + const apiKeyInput = providerConfig?.apiKey; + if (apiKeyInput === undefined || apiKeyInput === null) { + return undefined; + } + + const directApiKey = normalizeOptionalSecretInput(apiKeyInput); + if (directApiKey !== undefined) { + const trimmed = normalizeApiKeyConfig(directApiKey).trim(); + if (!trimmed) { + return undefined; + } + if (isKnownEnvApiKeyMarker(trimmed)) { + const envValue = normalizeOptionalSecretInput((params.env ?? process.env)[trimmed]); + return envValue; + } + return isNonSecretApiKeyMarker(trimmed) ? undefined : trimmed; + } + + if (!params.config) { + return undefined; + } + const path = params.path ?? "models.providers.lmstudio.apiKey"; + const resolved = await resolveConfiguredSecretInputString({ + config: params.config, + env: params.env ?? process.env, + value: apiKeyInput, + path, + unresolvedReasonStyle: "detailed", + }); + if (resolved.unresolvedRefReason) { + throw new Error(`${path}: ${resolved.unresolvedRefReason}`); + } + const resolvedValue = normalizeOptionalSecretInput(resolved.value); + const trimmedResolvedValue = resolvedValue ? normalizeApiKeyConfig(resolvedValue).trim() : ""; + if (!trimmedResolvedValue) { + return undefined; + } + if (isNonSecretApiKeyMarker(trimmedResolvedValue)) { + return undefined; + } + return trimmedResolvedValue; +} + +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; + } + + if (!params.config) { + return sanitizeStringHeaders(headerInputs); + } + + const pathPrefix = params.path ?? "models.providers.lmstudio.headers"; + const resolved: Record = {}; + for (const [headerName, headerValue] of Object.entries(headerInputs)) { + const resolvedHeader = await resolveConfiguredSecretInputString({ + config: params.config, + env: params.env ?? process.env, + value: headerValue, + path: `${pathPrefix}.${headerName}`, + unresolvedReasonStyle: "detailed", + }); + if (resolvedHeader.unresolvedRefReason) { + throw new Error(`${pathPrefix}.${headerName}: ${resolvedHeader.unresolvedRefReason}`); + } + const resolvedValue = resolvedHeader.value; + if (!resolvedValue) { + continue; + } + resolved[headerName] = resolvedValue; + } + return Object.keys(resolved).length > 0 ? resolved : undefined; +} + +/** + * Resolves LM Studio API key and provider headers in parallel. + * Use this as the standard auth setup step before discovery or model load calls. + */ +export async function resolveLmstudioRequestContext(params: { + config?: OpenClawConfig; + agentDir?: string; + env?: NodeJS.ProcessEnv; + providerHeaders?: unknown; +}): Promise<{ apiKey: string | undefined; headers: Record | undefined }> { + const providerHeaders = + params.providerHeaders ?? params.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID]?.headers; + const [apiKey, headers] = await Promise.all([ + resolveLmstudioRuntimeApiKey({ + config: params.config, + agentDir: params.agentDir, + env: params.env, + headers: providerHeaders, + }), + resolveLmstudioProviderHeaders({ + config: params.config, + env: params.env, + headers: providerHeaders, + }), + ]); + return { apiKey, headers }; +} + +/** + * Resolves LM Studio runtime API key from config. + */ +export async function resolveLmstudioRuntimeApiKey(params: { + config?: OpenClawConfig; + agentDir?: string; + env?: NodeJS.ProcessEnv; + headers?: unknown; +}): Promise { + const config = params.config; + if (!config) { + return undefined; + } + const providerHeaders = + params.headers ?? config.models?.providers?.[LMSTUDIO_PROVIDER_ID]?.headers; + const hasAuthorizationHeader = hasLmstudioAuthorizationHeader(providerHeaders); + let configuredApiKeyPromise: Promise | undefined; + const getConfiguredApiKey = async () => { + configuredApiKeyPromise ??= resolveLmstudioConfiguredApiKey({ + config, + env: params.env, + }); + return await configuredApiKeyPromise; + }; + const resolveConfiguredApiKeyOrThrow = async () => { + const configuredApiKey = await getConfiguredApiKey(); + if (configuredApiKey) { + return configuredApiKey; + } + if (hasAuthorizationHeader) { + return undefined; + } + const envMarker = `\${${LMSTUDIO_DEFAULT_API_KEY_ENV_VAR}}`; + throw new Error( + [ + "LM Studio API key is required.", + `Set models.providers.lmstudio.apiKey (for example "${envMarker}")`, + 'or run "openclaw models auth lmstudio".', + ].join(" "), + ); + }; + let resolved: Awaited>; + try { + resolved = await resolveApiKeyForProvider({ + provider: LMSTUDIO_PROVIDER_ID, + cfg: config, + agentDir: params.agentDir, + }); + } catch { + return await resolveConfiguredApiKeyOrThrow(); + } + // Normalize empty/whitespace keys to undefined for callers. + const resolvedApiKey = resolved.apiKey?.trim(); + if (!resolvedApiKey || resolvedApiKey.length === 0) { + return await resolveConfiguredApiKeyOrThrow(); + } + if (isNonSecretApiKeyMarker(resolvedApiKey) && resolvedApiKey !== CUSTOM_LOCAL_AUTH_MARKER) { + return await resolveConfiguredApiKeyOrThrow(); + } + return resolvedApiKey; +} diff --git a/extensions/lmstudio/src/setup.test.ts b/extensions/lmstudio/src/setup.test.ts new file mode 100644 index 00000000000..8b7c1cda04a --- /dev/null +++ b/extensions/lmstudio/src/setup.test.ts @@ -0,0 +1,1232 @@ +import { CUSTOM_LOCAL_AUTH_MARKER } from "openclaw/plugin-sdk/provider-auth"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth"; +import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { resolveAgentModelPrimaryValue } from "openclaw/plugin-sdk/provider-onboard"; +import { + SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + type ProviderAuthMethodNonInteractiveContext, + type ProviderCatalogContext, +} from "openclaw/plugin-sdk/provider-setup"; +import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, +} from "./defaults.js"; +import { + configureLmstudioNonInteractive, + discoverLmstudioProvider, + promptAndConfigureLmstudioInteractive, +} from "./setup.js"; + +const fetchLmstudioModelsMock = vi.hoisted(() => vi.fn()); +const discoverLmstudioModelsMock = vi.hoisted(() => vi.fn()); +const configureSelfHostedNonInteractiveMock = vi.hoisted(() => vi.fn()); +const removeProviderAuthProfilesWithLockMock = vi.hoisted(() => vi.fn()); + +vi.mock("./models.fetch.js", () => ({ + fetchLmstudioModels: (...args: unknown[]) => fetchLmstudioModelsMock(...args), + discoverLmstudioModels: (...args: unknown[]) => discoverLmstudioModelsMock(...args), + ensureLmstudioModelLoaded: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + removeProviderAuthProfilesWithLock: (...args: unknown[]) => + removeProviderAuthProfilesWithLockMock(...args), + }; +}); + +vi.mock("openclaw/plugin-sdk/provider-setup", 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; +}): ProviderCatalogContext { + return { + config: params?.config ?? ({} as OpenClawConfig), + env: params?.env ?? {}, + resolveProviderApiKey: () => ({ + apiKey: params?.apiKey, + discoveryApiKey: params?.discoveryApiKey, + }), + resolveProviderAuth: () => ({ + apiKey: params?.apiKey, + discoveryApiKey: params?.discoveryApiKey, + mode: "none" as const, + source: "none" as const, + }), + }; +} + +function buildNonInteractiveContext(params?: { + config?: OpenClawConfig; + customBaseUrl?: string; + customApiKey?: string; + lmstudioApiKey?: string; + customModelId?: string; + resolvedApiKey?: string | null; + resolvedApiKeySource?: "flag" | "env" | "profile"; +}): ProviderAuthMethodNonInteractiveContext & { + runtime: { + error: ReturnType; + exit: ReturnType; + log: ReturnType; + }; + resolveApiKey: ReturnType; + toApiKeyCredential: ReturnType; +} { + const error = vi.fn<(...args: unknown[]) => void>(); + const exit = vi.fn<(code: number) => void>(); + const log = vi.fn<(...args: unknown[]) => void>(); + const resolveApiKey = vi.fn(async () => + params?.resolvedApiKey === null + ? null + : { + key: params?.resolvedApiKey ?? "lmstudio-test-key", + source: params?.resolvedApiKeySource ?? "flag", + }, + ); + const toApiKeyCredential = vi.fn(); + return { + authChoice: "lmstudio", + config: params?.config ?? buildConfig(), + baseConfig: params?.config ?? buildConfig(), + opts: { + customBaseUrl: params?.customBaseUrl, + customApiKey: params?.customApiKey ?? "lmstudio-test-key", + lmstudioApiKey: params?.lmstudioApiKey, + customModelId: params?.customModelId, + } as ProviderAuthMethodNonInteractiveContext["opts"], + runtime: { error, exit, log }, + resolveApiKey, + toApiKeyCredential, + }; +} + +function createQueuedWizardPrompterHarness(textValues: string[]): { + prompter: WizardPrompter; + note: ReturnType; + text: ReturnType; +} { + const queue = [...textValues]; + const note = vi.fn(async (_message: string, _title?: string) => {}); + const text = vi.fn(async () => queue.shift() ?? ""); + const prompter: WizardPrompter = { + intro: async () => {}, + outro: async () => {}, + note, + select: async (params: { options: Array<{ value: T }> }) => { + const firstOption = params.options[0]; + if (!firstOption) { + throw new Error("select called without options"); + } + return firstOption.value; + }, + multiselect: async () => [], + text, + confirm: async () => false, + progress: () => ({ + update: () => {}, + stop: () => {}, + }), + }; + return { prompter, note, text }; +} + +describe("lmstudio setup", () => { + beforeEach(() => { + fetchLmstudioModelsMock.mockReset(); + discoverLmstudioModelsMock.mockReset(); + configureSelfHostedNonInteractiveMock.mockReset(); + removeProviderAuthProfilesWithLockMock.mockReset(); + + fetchLmstudioModelsMock.mockResolvedValue({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + }, + ], + }); + discoverLmstudioModelsMock.mockResolvedValue([createModel("qwen3-8b-instruct", "Qwen3 8B")]); + configureSelfHostedNonInteractiveMock.mockImplementation( + async ({ + providerId, + ctx, + }: { + providerId: string; + ctx: ProviderAuthMethodNonInteractiveContext; + }) => { + const modelId = + (typeof ctx.opts.customModelId === "string" ? ctx.opts.customModelId.trim() : "") || + "qwen3-8b-instruct"; + return { + agents: { defaults: { model: { primary: `${providerId}/${modelId}` } } }, + models: { + providers: { + [providerId]: { api: "openai-completions", auth: "api-key", apiKey: "LM_API_TOKEN" }, + }, + }, + }; + }, + ); + }); + + it("non-interactive setup discovers catalog and writes LM Studio provider config", async () => { + const ctx = buildNonInteractiveContext({ + customBaseUrl: "http://localhost:1234/api/v1/", + customModelId: "qwen3-8b-instruct", + }); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "qwen3-8b-instruct", + display_name: "Qwen3 8B", + loaded_instances: [{ id: "inst-1", config: { context_length: 64000 } }], + }, + { + type: "embedding", + key: "text-embedding-nomic-embed-text-v1.5", + }, + ], + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "lmstudio-test-key", + timeoutMs: 5000, + }); + expect(result?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + models: [ + { + id: "qwen3-8b-instruct", + contextWindow: SELF_HOSTED_DEFAULT_CONTEXT_WINDOW, + contextTokens: 64000, + }, + ], + }); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe( + "lmstudio/qwen3-8b-instruct", + ); + }); + + it("non-interactive setup preserves existing custom headers when CLI auth is provided", async () => { + const ctx = buildNonInteractiveContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + headers: { + Authorization: "Bearer stale-token", + "X-Proxy-Auth": "proxy-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/api/v1/", + customModelId: "qwen3-8b-instruct", + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(result?.models?.providers?.lmstudio).toMatchObject({ + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + headers: { + Authorization: "Bearer stale-token", + "X-Proxy-Auth": "proxy-token", + }, + }); + }); + + it("non-interactive setup auto-selects a discovered LM Studio model when none is provided", async () => { + const ctx = buildNonInteractiveContext({ + customBaseUrl: "http://localhost:1234/api/v1/", + }); + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "phi-4", + max_context_length: 65536, + }, + { + type: "llm", + key: "qwen3-8b-instruct", + display_name: "Qwen3 8B", + }, + ], + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(configureSelfHostedNonInteractiveMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + opts: expect.objectContaining({ + customModelId: "phi-4", + }), + }), + }), + ); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe("lmstudio/phi-4"); + expect(result?.models?.providers?.lmstudio?.models).toEqual([ + expect.objectContaining({ + id: "phi-4", + contextWindow: 65536, + }), + expect.objectContaining({ + id: "qwen3-8b-instruct", + }), + ]); + }); + + it("non-interactive setup synthesizes lmstudio-local when API key is missing", async () => { + const ctx = buildNonInteractiveContext({ + customBaseUrl: "http://localhost:1234/api/v1/", + customModelId: "qwen3-8b-instruct", + resolvedApiKey: null, + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + timeoutMs: 5000, + }); + expect(result?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + models: [ + { + id: "qwen3-8b-instruct", + }, + ], + }); + }); + + it("non-interactive setup keeps Authorization header auth without writing a synthetic key", async () => { + const ctx = buildNonInteractiveContext({ + config: { + auth: { + profiles: { + "lmstudio:default": { + provider: "lmstudio", + mode: "api_key", + }, + }, + order: { + lmstudio: ["lmstudio:default"], + }, + }, + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + apiKey: "stale-config-key", + auth: "api-key", + api: "openai-completions", + headers: { + Authorization: "Bearer proxy-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/api/v1/", + customApiKey: "", + customModelId: "qwen3-8b-instruct", + resolvedApiKey: null, + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({ + provider: "lmstudio", + agentDir: undefined, + }); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + headers: { + Authorization: "Bearer proxy-token", + }, + timeoutMs: 5000, + }); + expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled(); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe( + "lmstudio/qwen3-8b-instruct", + ); + expect(result?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + Authorization: "Bearer proxy-token", + }, + models: [ + { + id: "qwen3-8b-instruct", + }, + ], + }); + expect(result?.models?.providers?.lmstudio).not.toHaveProperty("apiKey"); + expect(result?.models?.providers?.lmstudio).not.toHaveProperty("auth"); + expect(result?.auth).toBeUndefined(); + }); + + it("non-interactive setup clears stale profile auth before switching to Authorization header auth", async () => { + const ctx = buildNonInteractiveContext({ + config: { + auth: { + profiles: { + "lmstudio:default": { + provider: "lmstudio", + mode: "api_key", + }, + }, + order: { + lmstudio: ["lmstudio:default"], + }, + }, + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + apiKey: "stale-config-key", + auth: "api-key", + api: "openai-completions", + headers: { + Authorization: "Bearer proxy-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/api/v1/", + customApiKey: "", + customModelId: "qwen3-8b-instruct", + resolvedApiKey: "stale-profile-key", + resolvedApiKeySource: "profile", + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({ + provider: "lmstudio", + agentDir: undefined, + }); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + headers: { + Authorization: "Bearer proxy-token", + }, + timeoutMs: 5000, + }); + expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled(); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe( + "lmstudio/qwen3-8b-instruct", + ); + expect(result?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + Authorization: "Bearer proxy-token", + }, + models: [ + { + id: "qwen3-8b-instruct", + }, + ], + }); + expect(result?.models?.providers?.lmstudio).not.toHaveProperty("apiKey"); + expect(result?.models?.providers?.lmstudio).not.toHaveProperty("auth"); + expect(result?.auth).toBeUndefined(); + }); + + it("non-interactive setup clears env fallback auth before switching to Authorization header auth", async () => { + const ctx = buildNonInteractiveContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + auth: "api-key", + api: "openai-completions", + headers: { + Authorization: "Bearer proxy-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/api/v1/", + customApiKey: "", + customModelId: "qwen3-8b-instruct", + resolvedApiKey: "env-fallback-key", + resolvedApiKeySource: "env", + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(removeProviderAuthProfilesWithLockMock).toHaveBeenCalledWith({ + provider: "lmstudio", + agentDir: undefined, + }); + expect(fetchLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: undefined, + headers: { + Authorization: "Bearer proxy-token", + }, + timeoutMs: 5000, + }); + expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled(); + expect(resolveAgentModelPrimaryValue(result?.agents?.defaults?.model)).toBe( + "lmstudio/qwen3-8b-instruct", + ); + expect(result?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + Authorization: "Bearer proxy-token", + }, + models: [ + { + id: "qwen3-8b-instruct", + }, + ], + }); + expect(result?.models?.providers?.lmstudio).not.toHaveProperty("apiKey"); + expect(result?.models?.providers?.lmstudio).not.toHaveProperty("auth"); + expect(result?.auth).toBeUndefined(); + }); + + it("non-interactive setup prefers --lmstudio-api-key over --custom-api-key", async () => { + const ctx = buildNonInteractiveContext({ + customBaseUrl: "http://localhost:1234/api/v1/", + customModelId: "qwen3-8b-instruct", + customApiKey: "old-custom-key", + lmstudioApiKey: "new-lmstudio-key", + }); + + await configureLmstudioNonInteractive(ctx); + + expect(ctx.resolveApiKey).toHaveBeenCalledWith( + expect.objectContaining({ + flagValue: "new-lmstudio-key", + flagName: "--lmstudio-api-key", + }), + ); + }); + + it("non-interactive setup overwrites existing config apiKey during re-auth", async () => { + const ctx = buildNonInteractiveContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + auth: "api-key", + apiKey: "stale-config-key", + api: "openai-completions", + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/api/v1/", + customModelId: "qwen3-8b-instruct", + lmstudioApiKey: "fresh-cli-key", + resolvedApiKey: "fresh-cli-key", + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(result?.models?.providers?.lmstudio).toMatchObject({ + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + }); + expect(result?.models?.providers?.lmstudio?.apiKey).not.toBe("stale-config-key"); + }); + + it("non-interactive setup fails when requested model is missing", async () => { + const ctx = buildNonInteractiveContext({ + customModelId: "missing-model", + }); + + await expect(configureLmstudioNonInteractive(ctx)).resolves.toBeNull(); + + expect(ctx.runtime.error).toHaveBeenCalledWith( + expect.stringContaining("LM Studio model missing-model was not found"), + ); + expect(ctx.runtime.exit).toHaveBeenCalledWith(1); + expect(configureSelfHostedNonInteractiveMock).not.toHaveBeenCalled(); + }); + + it("interactive setup canonicalizes base URL and persists provider/default model", async () => { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("lmstudio-test-key"); + + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + }); + + expect(result.configPatch?.models?.mode).toBe("merge"); + expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + auth: "api-key", + apiKey: "LM_API_TOKEN", + }); + expect(result.defaultModel).toBe("lmstudio/qwen3-8b-instruct"); + expect(result.profiles[0]).toMatchObject({ + profileId: "lmstudio:default", + credential: { + type: "api_key", + provider: "lmstudio", + key: "lmstudio-test-key", + }, + }); + }); + + it("interactive setup applies an optional preferred context length to all discovered LM Studio models", async () => { + fetchLmstudioModelsMock.mockResolvedValueOnce({ + reachable: true, + status: 200, + models: [ + { + type: "llm", + key: "phi-4", + display_name: "Phi 4", + max_context_length: 65536, + }, + { + type: "llm", + key: "qwen3-8b-instruct", + display_name: "Qwen3 8B", + max_context_length: 32768, + }, + ], + }); + const { prompter, text } = createQueuedWizardPrompterHarness([ + "http://localhost:1234/api/v1/", + "lmstudio-test-key", + "4096", + ]); + + const result = await promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + prompter, + }); + + expect(text).toHaveBeenCalledTimes(3); + expect(result.configPatch?.models?.providers?.lmstudio?.models).toEqual([ + expect.objectContaining({ + id: "phi-4", + contextWindow: 65536, + contextTokens: 4096, + maxTokens: 4096, + }), + expect.objectContaining({ + id: "qwen3-8b-instruct", + contextWindow: 32768, + contextTokens: 4096, + maxTokens: 4096, + }), + ]); + }); + + it("interactive setup overwrites existing config apiKey during re-auth", async () => { + const config = { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + auth: "api-key", + apiKey: "stale-config-key", + api: "openai-completions", + models: [], + }, + }, + }, + } as OpenClawConfig; + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("fresh-prompt-key"); + + const result = await promptAndConfigureLmstudioInteractive({ + config, + promptText, + }); + expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({ + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + }); + expect(result.configPatch?.models?.providers?.lmstudio?.apiKey).not.toBe("stale-config-key"); + expect(result.profiles[0]).toMatchObject({ + profileId: "lmstudio:default", + credential: { + type: "api_key", + provider: "lmstudio", + key: "fresh-prompt-key", + }, + }); + }); + + it("interactive setup preserves existing custom headers when switching to api-key auth", async () => { + const config = { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + headers: { + Authorization: "Bearer stale-token", + "X-Proxy-Auth": "proxy-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig; + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("lmstudio-test-key"); + + const result = await promptAndConfigureLmstudioInteractive({ + config, + promptText, + }); + expect(result.configPatch?.models?.providers?.lmstudio).toMatchObject({ + auth: "api-key", + apiKey: "LM_API_TOKEN", + headers: { + Authorization: "Bearer stale-token", + "X-Proxy-Auth": "proxy-token", + }, + }); + }); + + it("interactive setup preserves existing agent model allowlist entries", async () => { + const config = { + agents: { + defaults: { + models: { + "anthropic/claude-sonnet-4-6": { + alias: "Sonnet", + }, + }, + }, + }, + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "LM_API_TOKEN", + models: [], + }, + }, + }, + } as OpenClawConfig; + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/api/v1/") + .mockResolvedValueOnce("lmstudio-test-key"); + + const result = await promptAndConfigureLmstudioInteractive({ + config, + promptText, + }); + expect(result.configPatch?.agents?.defaults?.models).toEqual({ + "anthropic/claude-sonnet-4-6": { + alias: "Sonnet", + }, + "lmstudio/qwen3-8b-instruct": {}, + }); + }); + + it("interactive setup returns clear errors for unreachable/http-empty results", async () => { + const cases = [ + { + name: "unreachable", + discovery: { reachable: false, models: [] }, + expectedError: "LM Studio not reachable", + }, + { + name: "http error", + discovery: { reachable: true, status: 401, models: [] }, + expectedError: "LM Studio discovery failed (401)", + }, + { + name: "no llm models", + discovery: { + reachable: true, + status: 200, + models: [{ type: "embedding", key: "text-embedding-nomic-embed-text-v1.5" }], + }, + expectedError: "No LM Studio models found", + }, + ]; + + for (const testCase of cases) { + const promptText = vi + .fn() + .mockResolvedValueOnce("http://localhost:1234/v1") + .mockResolvedValueOnce("lmstudio-test-key"); + fetchLmstudioModelsMock.mockResolvedValueOnce(testCase.discovery); + await expect( + promptAndConfigureLmstudioInteractive({ + config: buildConfig(), + promptText, + }), + testCase.name, + ).rejects.toThrow(testCase.expectedError); + } + }); + + it.each([ + { + name: "injects lmstudio-local for explicit models by default", + providerPatch: {}, + expectedProviderPatch: { + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + }, + }, + { + name: "keeps api-key auth backed by default env marker", + providerPatch: { + auth: "api-key", + }, + expectedProviderPatch: { + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + }, + }, + { + name: "does not inject api-key marker when Authorization header is configured", + providerPatch: { + apiKey: "stale-legacy-key", + headers: { + Authorization: "Bearer custom-token", + }, + }, + expectedProviderPatch: { + headers: { + Authorization: "Bearer custom-token", + }, + }, + }, + { + name: "still injects lmstudio-local when only non-auth headers are configured", + providerPatch: { + headers: { + "X-Proxy-Auth": "proxy-token", + }, + }, + expectedProviderPatch: { + apiKey: LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + headers: { + "X-Proxy-Auth": "proxy-token", + }, + }, + }, + ])( + "discoverLmstudioProvider short-circuits explicit models and $name", + async ({ providerPatch, expectedProviderPatch }) => { + const explicitModels = [createModel("qwen3-8b-instruct", "Qwen3 8B")]; + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/api/v1/", + models: explicitModels, + ...providerPatch, + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(discoverLmstudioModelsMock).not.toHaveBeenCalled(); + expect(result).toEqual({ + provider: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + ...expectedProviderPatch, + models: explicitModels, + }, + }); + }, + ); + + it("discoverLmstudioProvider uses resolved key/headers and non-quiet discovery", async () => { + discoverLmstudioModelsMock.mockResolvedValueOnce([ + createModel("qwen3-8b-instruct", "Qwen3 8B"), + ]); + + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: { + source: "env", + provider: "default", + id: "LMSTUDIO_DISCOVERY_TOKEN", + }, + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: { + LMSTUDIO_DISCOVERY_TOKEN: "secretref-lmstudio-key", + LMSTUDIO_PROXY_TOKEN: "proxy-token-from-env", + }, + }), + ); + + expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "secretref-lmstudio-key", + headers: { + "X-Proxy-Auth": "proxy-token-from-env", + }, + quiet: false, + }); + expect(result?.provider.models?.map((model) => model.id)).toEqual(["qwen3-8b-instruct"]); + }); + + it("discoverLmstudioProvider returns null for unresolved header refs", async () => { + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + "X-Proxy-Auth": { + source: "env", + provider: "default", + id: "LMSTUDIO_PROXY_TOKEN", + }, + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + }), + ); + + expect(result).toBeNull(); + expect(discoverLmstudioModelsMock).not.toHaveBeenCalled(); + }); + + it("discoverLmstudioProvider returns null for an unresolved apiKey ref", async () => { + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: { + source: "env", + provider: "default", + id: "LMSTUDIO_DISCOVERY_TOKEN", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + env: {}, + }), + ); + + expect(result).toBeNull(); + expect(discoverLmstudioModelsMock).not.toHaveBeenCalled(); + }); + + it("discoverLmstudioProvider uses configured direct apiKey for discovery", async () => { + discoverLmstudioModelsMock.mockResolvedValueOnce([ + createModel("qwen3-8b-instruct", "Qwen3 8B"), + ]); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "configured-direct-key", + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "configured-direct-key", + headers: undefined, + quiet: false, + }); + }); + + it("discoverLmstudioProvider prefers resolved discoveryApiKey over configured apiKey", async () => { + discoverLmstudioModelsMock.mockResolvedValueOnce([ + createModel("qwen3-8b-instruct", "Qwen3 8B"), + ]); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + discoveryApiKey: "resolved-discovery-key", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "configured-direct-key", + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "resolved-discovery-key", + headers: undefined, + quiet: false, + }); + }); + + it("discoverLmstudioProvider suppresses stale discovery apiKey when Authorization header auth is configured", async () => { + discoverLmstudioModelsMock.mockResolvedValueOnce([ + createModel("qwen3-8b-instruct", "Qwen3 8B"), + ]); + + await discoverLmstudioProvider( + buildDiscoveryContext({ + discoveryApiKey: "resolved-stale-key", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "configured-direct-key", + headers: { + Authorization: "Bearer custom-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "", + headers: { + Authorization: "Bearer custom-token", + }, + quiet: false, + }); + }); + + it("discoverLmstudioProvider rewrites stale api-key auth without a persisted key", async () => { + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + auth: "api-key", + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(result?.provider).toMatchObject({ + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + models: [expect.objectContaining({ id: "qwen3-8b-instruct" })], + }); + }); + + it("discoverLmstudioProvider drops stale apiKey when Authorization header auth is configured", async () => { + const result = await discoverLmstudioProvider( + buildDiscoveryContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + apiKey: "stale-legacy-key", + headers: { + Authorization: "Bearer custom-token", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + }), + ); + + expect(result?.provider).toMatchObject({ + baseUrl: "http://localhost:1234/v1", + api: "openai-completions", + headers: { + Authorization: "Bearer custom-token", + }, + models: [expect.objectContaining({ id: "qwen3-8b-instruct" })], + }); + expect(result?.provider.apiKey).toBeUndefined(); + expect(result?.provider.auth).toBeUndefined(); + }); + + it("discoverLmstudioProvider uses quiet mode and returns null when unconfigured", async () => { + discoverLmstudioModelsMock.mockResolvedValueOnce([]); + + const result = await discoverLmstudioProvider(buildDiscoveryContext()); + + expect(discoverLmstudioModelsMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "", + quiet: true, + headers: undefined, + }); + expect(result).toBeNull(); + }); + + it("non-interactive setup replaces local auth markers when enabling api-key auth", async () => { + const ctx = buildNonInteractiveContext({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/v1", + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + api: "openai-completions", + models: [], + }, + }, + }, + } as OpenClawConfig, + customBaseUrl: "http://localhost:1234/api/v1/", + customModelId: "qwen3-8b-instruct", + }); + + const result = await configureLmstudioNonInteractive(ctx); + + expect(result?.models?.providers?.lmstudio).toMatchObject({ + auth: "api-key", + apiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + }); + }); +}); diff --git a/extensions/lmstudio/src/setup.ts b/extensions/lmstudio/src/setup.ts new file mode 100644 index 00000000000..1ab902dff71 --- /dev/null +++ b/extensions/lmstudio/src/setup.ts @@ -0,0 +1,826 @@ +import { + removeProviderAuthProfilesWithLock, + buildApiKeyCredential, + ensureApiKeyFromEnvOrPrompt, + normalizeOptionalSecretInput, + type OpenClawConfig, + type SecretInput, + type SecretInputMode, +} from "openclaw/plugin-sdk/provider-auth"; +import type { + ModelDefinitionConfig, + ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; +import { withAgentModelAliases } from "openclaw/plugin-sdk/provider-onboard"; +import { + applyProviderDefaultModel, + configureOpenAICompatibleSelfHostedProviderNonInteractive, + type ProviderAuthMethodNonInteractiveContext, + type ProviderAuthResult, + type ProviderCatalogContext, + type ProviderPrepareDynamicModelContext, + type ProviderRuntimeModel, +} from "openclaw/plugin-sdk/provider-setup"; +import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup"; +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 "./defaults.js"; +import { discoverLmstudioModels, fetchLmstudioModels } from "./models.fetch.js"; +import { + mapLmstudioWireModelsToConfig, + type LmstudioModelWire, + resolveLmstudioInferenceBase, +} from "./models.js"; +import { + hasLmstudioAuthorizationHeader, + resolveLmstudioProviderAuthMode, + shouldUseLmstudioApiKeyPlaceholder, +} from "./provider-auth.js"; +import { + resolveLmstudioConfiguredApiKey, + resolveLmstudioProviderHeaders, + resolveLmstudioRequestContext, +} from "./runtime.js"; + +type ProviderPromptText = (params: { + message: string; + initialValue?: string; + placeholder?: string; + validate?: (value: string | undefined) => string | undefined; +}) => Promise; + +type ProviderPromptNote = (message: string, title?: string) => Promise | void; +type LmstudioDiscoveryResult = Awaited>; +type LmstudioSetupDiscovery = { + discovery: LmstudioDiscoveryResult; + models: ModelDefinitionConfig[]; + defaultModel: string | undefined; + defaultModelId: string | undefined; +}; + +function stripLmstudioStoredAuthConfig(cfg: OpenClawConfig): OpenClawConfig { + const { profiles: _profiles, order: _order, ...restAuth } = cfg.auth ?? {}; + const nextProfiles = Object.fromEntries( + Object.entries(cfg.auth?.profiles ?? {}).filter( + ([, profile]) => profile.provider !== PROVIDER_ID, + ), + ); + const nextOrder = Object.fromEntries( + Object.entries(cfg.auth?.order ?? {}).filter(([providerId]) => providerId !== PROVIDER_ID), + ); + return { + ...cfg, + auth: + Object.keys(restAuth).length > 0 || + Object.keys(nextProfiles).length > 0 || + Object.keys(nextOrder).length > 0 + ? { + ...restAuth, + ...(Object.keys(nextProfiles).length > 0 ? { profiles: nextProfiles } : {}), + ...(Object.keys(nextOrder).length > 0 ? { order: nextOrder } : {}), + } + : undefined, + }; +} + +function resolvePositiveInteger(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) { + const normalized = Math.floor(value); + return normalized > 0 ? normalized : undefined; + } + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || !/^\d+$/.test(trimmed)) { + return undefined; + } + const normalized = Number.parseInt(trimmed, 10); + return Number.isFinite(normalized) && normalized > 0 ? normalized : undefined; +} + +function buildLmstudioSetupProviderConfig(params: { + existingProvider: ModelProviderConfig | undefined; + sharedProvider?: ModelProviderConfig; + baseUrl: string; + apiKey?: ModelProviderConfig["apiKey"]; + headers: ModelProviderConfig["headers"] | undefined; + models: ModelDefinitionConfig[]; +}): ModelProviderConfig { + const existingWithoutAuth = params.existingProvider + ? (({ auth: _auth, apiKey: _apiKey, ...rest }) => rest)(params.existingProvider) + : undefined; + const sharedWithoutAuth = params.sharedProvider + ? (({ auth: _auth, apiKey: _apiKey, ...rest }) => rest)(params.sharedProvider) + : undefined; + const resolvedAuth = resolveLmstudioProviderAuthMode(params.apiKey); + return { + ...existingWithoutAuth, + ...sharedWithoutAuth, + baseUrl: params.baseUrl, + api: params.sharedProvider?.api ?? params.existingProvider?.api ?? "openai-completions", + ...(resolvedAuth ? { auth: resolvedAuth } : {}), + ...(params.apiKey !== undefined ? { apiKey: params.apiKey } : {}), + headers: params.headers, + models: params.models, + }; +} + +function resolveLmstudioModelAdvertisedContextLimit(entry: LmstudioModelWire): number | undefined { + const raw = entry.max_context_length; + if (raw === undefined || !Number.isFinite(raw) || raw <= 0) { + return undefined; + } + return Math.floor(raw); +} + +function applyModelContextTokensOverride( + model: ModelDefinitionConfig, + contextTokens: number, +): ModelDefinitionConfig { + return { + ...model, + contextTokens, + maxTokens: Math.min(model.maxTokens, contextTokens), + }; +} + +function applyRequestedContextWindowToAllModels(params: { + models: ModelDefinitionConfig[]; + discoveryModels: LmstudioModelWire[]; + requestedContextWindow?: number; +}): ModelDefinitionConfig[] { + const requestedContextWindow = params.requestedContextWindow; + if (!requestedContextWindow) { + return params.models; + } + const contextLimitByModelId = new Map( + params.discoveryModels + .map((entry) => { + const modelId = entry.key?.trim(); + if (!modelId) { + return null; + } + return [modelId, resolveLmstudioModelAdvertisedContextLimit(entry)] as const; + }) + .filter((entry): entry is readonly [string, number | undefined] => Boolean(entry)), + ); + return params.models.map((model) => + applyModelContextTokensOverride( + model, + Math.min( + requestedContextWindow, + contextLimitByModelId.get(model.id) ?? requestedContextWindow, + ), + ), + ); +} + +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 resolvePersistedLmstudioApiKey(params: { + currentApiKey: ModelProviderConfig["apiKey"] | undefined; + explicitAuth: ModelProviderConfig["auth"] | undefined; + fallbackApiKey: ModelProviderConfig["apiKey"] | undefined; + preferFallbackApiKey?: boolean; + hasModels: boolean; + hasAuthorizationHeader?: boolean; +}): ModelProviderConfig["apiKey"] | undefined { + if (params.explicitAuth === "api-key") { + if (params.preferFallbackApiKey && params.fallbackApiKey !== undefined) { + return params.fallbackApiKey; + } + if (resolveLmstudioProviderAuthMode(params.currentApiKey)) { + return params.currentApiKey; + } + return params.fallbackApiKey; + } + return shouldUseLmstudioApiKeyPlaceholder({ + hasModels: params.hasModels, + resolvedApiKey: params.currentApiKey, + hasAuthorizationHeader: params.hasAuthorizationHeader, + }) + ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER + : undefined; +} + +/** Keeps explicit model entries first and appends unique discovered entries. */ +function mergeDiscoveredModels(params: { + explicitModels?: ModelDefinitionConfig[]; + discoveredModels?: ModelDefinitionConfig[]; +}): ModelDefinitionConfig[] { + const explicitModels = Array.isArray(params.explicitModels) ? params.explicitModels : []; + const discoveredModels = Array.isArray(params.discoveredModels) ? params.discoveredModels : []; + if (explicitModels.length === 0) { + return discoveredModels; + } + if (discoveredModels.length === 0) { + return explicitModels; + } + + const merged = [...explicitModels]; + const seen = new Set(explicitModels.map((model) => model.id.trim()).filter(Boolean)); + for (const model of discoveredModels) { + const id = model.id.trim(); + if (!id || seen.has(id)) { + continue; + } + seen.add(id); + merged.push(model); + } + return merged; +} + +async function discoverLmstudioProviderCatalog(params: { + baseUrl?: string; + apiKey?: string; + headers?: Record; + quiet: boolean; +}): Promise { + const baseUrl = resolveLmstudioInferenceBase(params.baseUrl); + const models = await discoverLmstudioModels({ + baseUrl, + apiKey: params.apiKey ?? "", + headers: params.headers, + quiet: params.quiet, + }); + return { + baseUrl, + api: "openai-completions", + models, + }; +} + +function isLmstudioDiscoveryConfigResolutionError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("models.providers.lmstudio.apiKey") || + message.includes("models.providers.lmstudio.headers.") + ); +} + +/** Preserves existing allowlist metadata and appends discovered LM Studio model refs. */ +function mergeDiscoveredLmstudioAllowlistEntries(params: { + existing?: NonNullable["defaults"]>["models"]; + discoveredModels: ModelDefinitionConfig[]; +}) { + return withAgentModelAliases( + params.existing, + params.discoveredModels + .map((model) => model.id.trim()) + .filter(Boolean) + .map((id) => `${PROVIDER_ID}/${id}`), + ); +} + +function selectDefaultLmstudioModelId( + discoveredModels: ModelDefinitionConfig[], +): string | undefined { + const ids = discoveredModels.map((model) => model.id.trim()).filter(Boolean); + if (ids.length === 0) { + return undefined; + } + return ids.includes(LMSTUDIO_DEFAULT_MODEL_ID) ? LMSTUDIO_DEFAULT_MODEL_ID : ids[0]; +} + +async function discoverLmstudioSetupModels(params: { + baseUrl: string; + apiKey?: string; + headers?: Record; + timeoutMs?: number; +}): Promise< + | { value: LmstudioSetupDiscovery } + | { failure: NonNullable> } +> { + const discovery = await fetchLmstudioModels({ + baseUrl: params.baseUrl, + apiKey: params.apiKey, + ...(params.headers ? { headers: params.headers } : {}), + timeoutMs: params.timeoutMs ?? 5000, + }); + const failure = resolveLmstudioDiscoveryFailure({ + baseUrl: params.baseUrl, + discovery, + }); + if (failure) { + return { failure }; + } + const models = mapLmstudioWireModelsToConfig(discovery.models); + const defaultModelId = selectDefaultLmstudioModelId(models); + return { + value: { + discovery, + models, + defaultModel: defaultModelId ? `${PROVIDER_ID}/${defaultModelId}` : undefined, + defaultModelId, + }, + }; +} + +/** 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(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-...", + 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]; + // Auth setup updates auth/profile/provider model fields but does not mutate + // user-provided header overrides. Runtime request assembly is the source of truth for auth. + const persistedHeaders = existingProvider?.headers; + const resolvedHeaders = await resolveLmstudioProviderHeaders({ + config: params.config, + env: process.env, + headers: persistedHeaders, + }); + const setupDiscovery = await discoverLmstudioSetupModels({ + baseUrl, + apiKey, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + timeoutMs: 5000, + }); + if ("failure" in setupDiscovery) { + await note?.(setupDiscovery.failure.noteLines.join("\n"), "LM Studio"); + throw new WizardCancelledError(setupDiscovery.failure.reason); + } + let discoveredModels = setupDiscovery.value.models; + if (params.prompter) { + const requestedRaw = await params.prompter.text({ + message: "Preferred context length to load LM Studio models with (optional)", + placeholder: "e.g. 32768 (leave blank to skip)", + validate: (value) => + value?.trim() + ? resolvePositiveInteger(value) + ? undefined + : "Enter a positive integer token count" + : undefined, + }); + const requestedContextWindow = resolvePositiveInteger(requestedRaw); + discoveredModels = applyRequestedContextWindowToAllModels({ + models: discoveredModels, + discoveryModels: setupDiscovery.value.discovery.models, + requestedContextWindow, + }); + } + const allowlistEntries = mergeDiscoveredLmstudioAllowlistEntries({ + existing: params.config.agents?.defaults?.models, + discoveredModels, + }); + const defaultModel = setupDiscovery.value.defaultModel; + const persistedApiKey = + resolvePersistedLmstudioApiKey({ + currentApiKey: existingProvider?.apiKey, + explicitAuth: resolveLmstudioProviderAuthMode(apiKey), + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + preferFallbackApiKey: true, + hasModels: discoveredModels.length > 0, + hasAuthorizationHeader: hasLmstudioAuthorizationHeader(resolvedHeaders), + }) ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR; + + return { + profiles: [ + { + profileId: `${PROVIDER_ID}:default`, + credential, + }, + ], + configPatch: { + agents: { + defaults: { + models: allowlistEntries, + }, + }, + models: { + // Respect existing global mode; self-hosted provider setup should merge by default. + mode: params.config.models?.mode ?? "merge", + providers: { + [PROVIDER_ID]: buildLmstudioSetupProviderConfig({ + existingProvider, + baseUrl, + apiKey: persistedApiKey, + headers: persistedHeaders, + models: discoveredModels, + }), + }, + }, + }, + defaultModel, + }; +} + +/** Non-interactive setup path backed by the shared self-hosted helper. */ +export async function configureLmstudioNonInteractive( + ctx: ProviderAuthMethodNonInteractiveContext, +): Promise { + const customBaseUrl = normalizeOptionalSecretInput(ctx.opts.customBaseUrl); + const baseUrl = resolveLmstudioInferenceBase( + customBaseUrl || LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + ); + const normalizedCtx = customBaseUrl + ? { + ...ctx, + opts: { + ...ctx.opts, + customBaseUrl: baseUrl, + }, + } + : ctx; + const configureShared = async (configureCtx: ProviderAuthMethodNonInteractiveContext) => + await configureOpenAICompatibleSelfHostedProviderNonInteractive({ + ctx: configureCtx, + providerId: PROVIDER_ID, + providerLabel: LMSTUDIO_PROVIDER_LABEL, + defaultBaseUrl: LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + defaultApiKeyEnvVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + modelPlaceholder: LMSTUDIO_MODEL_PLACEHOLDER, + }); + const requestedModelId = normalizeOptionalSecretInput(normalizedCtx.opts.customModelId); + const resolved = await normalizedCtx.resolveApiKey({ + provider: PROVIDER_ID, + flagValue: + normalizeOptionalSecretInput(normalizedCtx.opts.lmstudioApiKey) ?? + normalizeOptionalSecretInput(normalizedCtx.opts.customApiKey), + flagName: + normalizeOptionalSecretInput(normalizedCtx.opts.lmstudioApiKey) !== undefined + ? "--lmstudio-api-key" + : "--custom-api-key", + envVar: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + envVarName: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + required: false, + }); + + const existingProvider = normalizedCtx.config.models?.providers?.[PROVIDER_ID]; + // Auth setup updates auth/profile/provider model fields but does not mutate + // user-provided header overrides. Runtime request assembly is the source of truth for auth. + const persistedHeaders = existingProvider?.headers; + const resolvedHeaders = await resolveLmstudioProviderHeaders({ + config: normalizedCtx.config, + env: process.env, + headers: persistedHeaders, + }); + const hasAuthorizationHeader = hasLmstudioAuthorizationHeader(resolvedHeaders); + const useHeaderOnlyAuth = hasAuthorizationHeader && (!resolved || resolved.source !== "flag"); + const setupDiscoveryApiKey = + (useHeaderOnlyAuth ? undefined : resolved?.key) ?? + (shouldUseLmstudioApiKeyPlaceholder({ + hasModels: true, + resolvedApiKey: undefined, + hasAuthorizationHeader, + }) + ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER + : undefined); + if (!setupDiscoveryApiKey && !hasAuthorizationHeader) { + normalizedCtx.runtime.error( + `LM Studio API key is required. Set ${LMSTUDIO_DEFAULT_API_KEY_ENV_VAR} or pass --lmstudio-api-key.`, + ); + normalizedCtx.runtime.exit(1); + return null; + } + const setupDiscovery = await discoverLmstudioSetupModels({ + baseUrl, + apiKey: setupDiscoveryApiKey, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + timeoutMs: 5000, + }); + if ("failure" in setupDiscovery) { + normalizedCtx.runtime.error(setupDiscovery.failure.noteLines.join("\n")); + normalizedCtx.runtime.exit(1); + return null; + } + const discoveredModels = setupDiscovery.value.models; + const selectedModelId = requestedModelId ?? setupDiscovery.value.defaultModelId; + const selectedModel = selectedModelId + ? discoveredModels.find((model) => model.id === selectedModelId) + : undefined; + if (!selectedModelId || !selectedModel) { + const availableModels = discoveredModels.map((model) => model.id).join(", "); + normalizedCtx.runtime.error( + requestedModelId + ? [ + `LM Studio model ${requestedModelId} was not found at ${baseUrl}.`, + `Available models: ${availableModels}`, + ].join("\n") + : [ + `LM Studio did not expose a usable default model at ${baseUrl}.`, + `Available models: ${availableModels || "(none)"}`, + ].join("\n"), + ); + normalizedCtx.runtime.exit(1); + return null; + } + if (useHeaderOnlyAuth) { + await removeProviderAuthProfilesWithLock({ + provider: PROVIDER_ID, + agentDir: normalizedCtx.agentDir, + }); + const configWithoutStoredLmstudioAuth = stripLmstudioStoredAuthConfig(normalizedCtx.config); + return applyProviderDefaultModel( + { + ...configWithoutStoredLmstudioAuth, + models: { + ...configWithoutStoredLmstudioAuth.models, + mode: configWithoutStoredLmstudioAuth.models?.mode ?? "merge", + providers: { + ...configWithoutStoredLmstudioAuth.models?.providers, + [PROVIDER_ID]: buildLmstudioSetupProviderConfig({ + existingProvider, + baseUrl, + headers: persistedHeaders, + models: discoveredModels, + }), + }, + }, + }, + `${PROVIDER_ID}/${selectedModelId}`, + ); + } + const resolvedOrSynthetic = + resolved ?? + (setupDiscoveryApiKey + ? { + key: setupDiscoveryApiKey, + source: "flag" as const, + } + : null); + if (!resolvedOrSynthetic) { + return null; + } + + // Delegate to the shared helper even when modelId is set so that onboarding + // state and credential storage are handled consistently. The pre-resolved key + // is injected via resolveApiKey to skip a second prompt. The returned config + // is then post-patched below to add the discovered model list and base URL. + const configured = await configureShared({ + ...normalizedCtx, + opts: { + ...normalizedCtx.opts, + customModelId: selectedModelId, + }, + resolveApiKey: async () => resolvedOrSynthetic, + }); + if (!configured) { + return null; + } + const sharedProvider = configured.models?.providers?.[PROVIDER_ID]; + const resolvedSyntheticLocalKey = resolvedOrSynthetic.key === LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER; + const persistedApiKey = resolvePersistedLmstudioApiKey({ + // If this run resolved to keyless local mode, avoid preserving stale env markers. + currentApiKey: resolvedSyntheticLocalKey ? undefined : existingProvider?.apiKey, + explicitAuth: resolveLmstudioProviderAuthMode(resolvedOrSynthetic.key), + fallbackApiKey: resolvedSyntheticLocalKey + ? LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER + : (configured.models?.providers?.[PROVIDER_ID]?.apiKey ?? LMSTUDIO_DEFAULT_API_KEY_ENV_VAR), + preferFallbackApiKey: true, + hasModels: discoveredModels.length > 0, + hasAuthorizationHeader: hasLmstudioAuthorizationHeader(resolvedHeaders), + }); + + return { + ...configured, + models: { + ...configured.models, + providers: { + ...configured.models?.providers, + [PROVIDER_ID]: buildLmstudioSetupProviderConfig({ + existingProvider, + sharedProvider, + baseUrl, + apiKey: persistedApiKey, + headers: persistedHeaders, + models: discoveredModels, + }), + }, + }, + }; +} + +/** Discovers provider settings, merging explicit config with live model discovery. */ +export async function discoverLmstudioProvider(ctx: ProviderCatalogContext): Promise<{ + provider: ModelProviderConfig; +} | null> { + const explicit = ctx.config.models?.providers?.[PROVIDER_ID]; + const explicitAuth = explicit?.auth; + let explicitWithoutHeaders: Omit | undefined; + if (explicit) { + const { headers: _headers, auth: _auth, apiKey: _apiKey, ...rest } = explicit; + explicitWithoutHeaders = rest; + } + const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0; + const { apiKey, discoveryApiKey } = ctx.resolveProviderApiKey(PROVIDER_ID); + let configuredDiscoveryApiKey: string | undefined; + try { + configuredDiscoveryApiKey = await resolveLmstudioConfiguredApiKey({ + config: ctx.config, + env: ctx.env, + }); + } catch (error) { + if (isLmstudioDiscoveryConfigResolutionError(error)) { + return null; + } + throw error; + } + let resolvedHeaders: Record | undefined; + try { + resolvedHeaders = await resolveLmstudioProviderHeaders({ + config: ctx.config, + env: ctx.env, + headers: explicit?.headers, + }); + } catch (error) { + if (isLmstudioDiscoveryConfigResolutionError(error)) { + return null; + } + throw error; + } + const hasAuthorizationHeader = hasLmstudioAuthorizationHeader(resolvedHeaders); + const resolvedDiscoveryApiKey = hasAuthorizationHeader + ? undefined + : (discoveryApiKey ?? configuredDiscoveryApiKey); + // CLI/runtime-resolved key takes precedence over static provider config key. + const resolvedApiKey = apiKey ?? explicit?.apiKey; + if (hasExplicitModels && explicitWithoutHeaders) { + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: resolvedApiKey, + explicitAuth, + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: hasExplicitModels, + hasAuthorizationHeader, + }); + const persistedAuth = resolveLmstudioProviderAuthMode(persistedApiKey); + return { + provider: { + ...explicitWithoutHeaders, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + baseUrl: resolveLmstudioInferenceBase(explicitWithoutHeaders.baseUrl), + // Keep explicit API unless absent, then fall back to provider default. + api: explicitWithoutHeaders.api ?? "openai-completions", + ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), + ...(persistedAuth ? { auth: persistedAuth } : {}), + models: explicitWithoutHeaders.models, + }, + }; + } + const provider = await discoverLmstudioProviderCatalog({ + baseUrl: explicit?.baseUrl, + // Prefer resolved discovery auth, then configured provider auth. + apiKey: resolvedDiscoveryApiKey, + headers: resolvedHeaders, + quiet: !apiKey && !explicit && !resolvedDiscoveryApiKey, + }); + const models = mergeDiscoveredModels({ + explicitModels: explicit?.models, + discoveredModels: provider.models, + }); + if (models.length === 0 && !apiKey && !explicit?.apiKey) { + return null; + } + const persistedApiKey = resolvePersistedLmstudioApiKey({ + currentApiKey: resolvedApiKey, + explicitAuth, + fallbackApiKey: LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + hasModels: models.length > 0, + hasAuthorizationHeader, + }); + const persistedAuth = resolveLmstudioProviderAuthMode(persistedApiKey); + return { + provider: { + ...provider, + ...explicitWithoutHeaders, + ...(resolvedHeaders ? { headers: resolvedHeaders } : {}), + baseUrl: resolveLmstudioInferenceBase(explicit?.baseUrl ?? provider.baseUrl), + ...(persistedApiKey ? { apiKey: persistedApiKey } : {}), + ...(persistedAuth ? { auth: persistedAuth } : {}), + models, + }, + }; +} + +export async function prepareLmstudioDynamicModels( + ctx: ProviderPrepareDynamicModelContext, +): Promise { + const baseUrl = resolveLmstudioInferenceBase(ctx.providerConfig?.baseUrl); + const { apiKey, headers } = await resolveLmstudioRequestContext({ + config: ctx.config, + agentDir: ctx.agentDir, + env: process.env, + providerHeaders: 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/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts new file mode 100644 index 00000000000..7b0a02312c6 --- /dev/null +++ b/extensions/lmstudio/src/stream.test.ts @@ -0,0 +1,290 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { wrapLmstudioInferencePreload } from "./stream.js"; + +const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn()); +const resolveLmstudioProviderHeadersMock = vi.hoisted(() => + vi.fn(async (_params?: unknown) => undefined), +); +const resolveLmstudioRuntimeApiKeyMock = vi.hoisted(() => + vi.fn(async (_params?: unknown) => undefined), +); + +vi.mock("./models.fetch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureLmstudioModelLoaded: (params: unknown) => ensureLmstudioModelLoadedMock(params), + }; +}); + +vi.mock("./runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveLmstudioProviderHeaders: (params: unknown) => resolveLmstudioProviderHeadersMock(params), + resolveLmstudioRuntimeApiKey: (params: unknown) => resolveLmstudioRuntimeApiKeyMock(params), + }; +}); + +type StreamEvent = { type: string }; + +async function collectEvents(stream: ReturnType): Promise { + const resolved = stream instanceof Promise ? await stream : stream; + const events: StreamEvent[] = []; + for await (const event of resolved) { + events.push(event as StreamEvent); + } + return events; +} + +function buildDoneStreamFn(): StreamFn { + return vi.fn((_model, _context, _options) => { + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "done", reason: "stop", message: {} as never }); + stream.end(); + }); + return stream; + }); +} + +describe("lmstudio stream wrapper", () => { + afterEach(() => { + ensureLmstudioModelLoadedMock.mockReset(); + resolveLmstudioProviderHeadersMock.mockReset(); + resolveLmstudioRuntimeApiKeyMock.mockReset(); + resolveLmstudioProviderHeadersMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue(undefined); + }); + + it("preloads LM Studio model before inference using model context window", async () => { + const baseStream = buildDoneStreamFn(); + const wrapped = wrapLmstudioInferencePreload({ + provider: "lmstudio", + modelId: "qwen3-8b-instruct", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://lmstudio.internal:1234/v1", + models: [], + }, + }, + }, + }, + streamFn: baseStream, + } as never); + + const stream = wrapped( + { + provider: "lmstudio", + api: "openai-completions", + id: "lmstudio/qwen3-8b-instruct", + contextWindow: 131072, + } as never, + { messages: [] } as never, + { apiKey: "lmstudio-token" } as never, + ); + const events = await collectEvents(stream); + + expect(events).toEqual([expect.objectContaining({ type: "done" })]); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "http://lmstudio.internal:1234/v1", + modelKey: "qwen3-8b-instruct", + requestedContextLength: 131072, + apiKey: "lmstudio-token", + ssrfPolicy: { allowedHostnames: ["lmstudio.internal"] }, + }), + ); + }); + + it("prefers model contextTokens over contextWindow for preload requests", async () => { + const baseStream = buildDoneStreamFn(); + const wrapped = wrapLmstudioInferencePreload({ + provider: "lmstudio", + modelId: "qwen3-8b-instruct", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://lmstudio.internal:1234/v1", + models: [], + }, + }, + }, + }, + streamFn: baseStream, + } as never); + + const stream = wrapped( + { + provider: "lmstudio", + api: "openai-completions", + id: "lmstudio/qwen3-8b-instruct", + contextWindow: 131072, + contextTokens: 64000, + } as never, + { messages: [] } as never, + { apiKey: "lmstudio-token" } as never, + ); + const events = await collectEvents(stream); + + expect(events).toEqual([expect.objectContaining({ type: "done" })]); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith( + expect.objectContaining({ + baseUrl: "http://lmstudio.internal:1234/v1", + modelKey: "qwen3-8b-instruct", + requestedContextLength: 64000, + apiKey: "lmstudio-token", + ssrfPolicy: { allowedHostnames: ["lmstudio.internal"] }, + }), + ); + }); + + it("continues inference when preload fails", async () => { + ensureLmstudioModelLoadedMock.mockRejectedValueOnce(new Error("load failed")); + const baseStream = buildDoneStreamFn(); + const wrapped = wrapLmstudioInferencePreload({ + provider: "lmstudio", + modelId: "qwen3-8b-instruct", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + models: [], + }, + }, + }, + }, + streamFn: baseStream, + } as never); + + const stream = wrapped( + { + provider: "lmstudio", + api: "openai-completions", + id: "qwen3-8b-instruct", + } as never, + { messages: [] } as never, + undefined as never, + ); + const events = await collectEvents(stream); + expect(events).toEqual([expect.objectContaining({ type: "done" })]); + expect(baseStream).toHaveBeenCalledTimes(1); + }); + + it("dedupes concurrent preload requests for the same model and context", async () => { + let resolvePreload: (() => void) | undefined; + ensureLmstudioModelLoadedMock.mockImplementationOnce( + () => + new Promise((resolve) => { + resolvePreload = resolve; + }), + ); + const baseStream = buildDoneStreamFn(); + const wrapped = wrapLmstudioInferencePreload({ + provider: "lmstudio", + modelId: "qwen3-8b-instruct", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + models: [], + }, + }, + }, + }, + streamFn: baseStream, + } as never); + + const first = wrapped( + { + provider: "lmstudio", + api: "openai-completions", + id: "qwen3-8b-instruct", + contextWindow: 32768, + } as never, + { messages: [] } as never, + undefined as never, + ); + const second = wrapped( + { + provider: "lmstudio", + api: "openai-completions", + id: "qwen3-8b-instruct", + contextWindow: 32768, + } as never, + { messages: [] } as never, + undefined as never, + ); + + const firstPromise = collectEvents(first); + const secondPromise = collectEvents(second); + await vi.waitFor(() => { + if (!resolvePreload) { + throw new Error("LM Studio preload resolver not initialized"); + } + }); + if (!resolvePreload) { + throw new Error("LM Studio preload resolver not initialized"); + } + resolvePreload(); + const [firstEvents, secondEvents] = await Promise.all([firstPromise, secondPromise]); + + expect(firstEvents).toEqual([expect.objectContaining({ type: "done" })]); + expect(secondEvents).toEqual([expect.objectContaining({ type: "done" })]); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + }); + + it("forces supportsUsageInStreaming compat before calling the underlying stream", async () => { + const baseStream = buildDoneStreamFn(); + const wrapped = wrapLmstudioInferencePreload({ + provider: "lmstudio", + modelId: "qwen3-8b-instruct", + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + models: [], + }, + }, + }, + }, + streamFn: baseStream, + } as never); + + const stream = wrapped( + { + provider: "lmstudio", + api: "openai-completions", + id: "qwen3-8b-instruct", + compat: { supportsDeveloperRole: false }, + } as never, + { messages: [] } as never, + undefined as never, + ); + const events = await collectEvents(stream); + + expect(events).toEqual([expect.objectContaining({ type: "done" })]); + expect(baseStream).toHaveBeenCalledTimes(1); + expect(baseStream).toHaveBeenCalledWith( + expect.objectContaining({ + provider: "lmstudio", + compat: expect.objectContaining({ + supportsDeveloperRole: false, + supportsUsageInStreaming: true, + }), + }), + expect.anything(), + undefined, + ); + }); +}); diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts new file mode 100644 index 00000000000..b0a63c25494 --- /dev/null +++ b/extensions/lmstudio/src/stream.ts @@ -0,0 +1,173 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { streamSimple } from "@mariozechner/pi-ai"; +import { createSubsystemLogger } from "openclaw/plugin-sdk/logging-core"; +import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry"; +import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; +import { LMSTUDIO_PROVIDER_ID } from "./defaults.js"; +import { ensureLmstudioModelLoaded } from "./models.fetch.js"; +import { resolveLmstudioInferenceBase } from "./models.js"; +import { resolveLmstudioProviderHeaders, resolveLmstudioRuntimeApiKey } from "./runtime.js"; + +const log = createSubsystemLogger("extensions/lmstudio/stream"); + +type StreamOptions = Parameters[2]; +type StreamModel = Parameters[0]; + +const preloadInFlight = new Map>(); + +function normalizeLmstudioModelKey(modelId: string): string { + const trimmed = modelId.trim(); + if (trimmed.toLowerCase().startsWith("lmstudio/")) { + return trimmed.slice("lmstudio/".length).trim(); + } + return trimmed; +} + +function resolveRequestedContextLength(model: StreamModel): number | undefined { + const withContextTokens = model as StreamModel & { contextTokens?: unknown }; + const contextTokens = + typeof withContextTokens.contextTokens === "number" && + Number.isFinite(withContextTokens.contextTokens) + ? Math.floor(withContextTokens.contextTokens) + : undefined; + if (contextTokens && contextTokens > 0) { + return contextTokens; + } + const contextWindow = + typeof model.contextWindow === "number" && Number.isFinite(model.contextWindow) + ? Math.floor(model.contextWindow) + : undefined; + if (contextWindow && contextWindow > 0) { + return contextWindow; + } + return undefined; +} + +function resolveModelHeaders(model: StreamModel): Record | undefined { + if (!model.headers || typeof model.headers !== "object" || Array.isArray(model.headers)) { + return undefined; + } + return model.headers; +} + +function createPreloadKey(params: { + baseUrl: string; + modelKey: string; + requestedContextLength?: number; +}) { + return `${params.baseUrl}::${params.modelKey}::${params.requestedContextLength ?? "default"}`; +} + +function buildLmstudioPreloadSsrFPolicy(baseUrl: string): SsrFPolicy | undefined { + const trimmed = baseUrl.trim(); + if (!trimmed) { + return undefined; + } + try { + const parsed = new URL(trimmed); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return undefined; + } + return { allowedHostnames: [parsed.hostname] }; + } catch { + return undefined; + } +} + +async function ensureLmstudioModelLoadedBestEffort(params: { + baseUrl: string; + modelKey: string; + requestedContextLength?: number; + options: StreamOptions; + ctx: ProviderWrapStreamFnContext; + modelHeaders?: Record; +}): Promise { + const providerConfig = params.ctx.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID]; + const providerHeaders = { ...providerConfig?.headers, ...params.modelHeaders }; + const runtimeApiKey = + typeof params.options?.apiKey === "string" && params.options.apiKey.trim().length > 0 + ? params.options.apiKey.trim() + : undefined; + const headers = await resolveLmstudioProviderHeaders({ + config: params.ctx.config, + headers: providerHeaders, + }); + const configuredApiKey = + runtimeApiKey !== undefined + ? undefined + : await resolveLmstudioRuntimeApiKey({ + config: params.ctx.config, + agentDir: params.ctx.agentDir, + headers: providerHeaders, + }); + + await ensureLmstudioModelLoaded({ + baseUrl: params.baseUrl, + apiKey: runtimeApiKey ?? configuredApiKey, + headers, + ssrfPolicy: buildLmstudioPreloadSsrFPolicy(params.baseUrl), + modelKey: params.modelKey, + requestedContextLength: params.requestedContextLength, + }); +} + +export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext): StreamFn { + const underlying = ctx.streamFn ?? streamSimple; + return (model, context, options) => { + if (model.provider !== LMSTUDIO_PROVIDER_ID) { + return underlying(model, context, options); + } + const modelKey = normalizeLmstudioModelKey(model.id); + if (!modelKey) { + return underlying(model, context, options); + } + const providerBaseUrl = ctx.config?.models?.providers?.[LMSTUDIO_PROVIDER_ID]?.baseUrl; + const resolvedBaseUrl = resolveLmstudioInferenceBase( + typeof model.baseUrl === "string" ? model.baseUrl : providerBaseUrl, + ); + const requestedContextLength = resolveRequestedContextLength(model); + const preloadKey = createPreloadKey({ + baseUrl: resolvedBaseUrl, + modelKey, + requestedContextLength, + }); + const existing = preloadInFlight.get(preloadKey); + const preloadPromise = + existing ?? + ensureLmstudioModelLoadedBestEffort({ + baseUrl: resolvedBaseUrl, + modelKey, + requestedContextLength, + options, + ctx, + modelHeaders: resolveModelHeaders(model), + }).finally(() => { + preloadInFlight.delete(preloadKey); + }); + if (!existing) { + preloadInFlight.set(preloadKey, preloadPromise); + } + + return (async () => { + try { + await preloadPromise; + } catch (error) { + log.warn( + `LM Studio inference preload failed for "${modelKey}"; continuing without preload: ${String(error)}`, + ); + } + // LM Studio uses OpenAI-compatible streaming usage payloads when requested via + // `stream_options.include_usage`. Force this compat flag at call time so usage + // reporting remains enabled even when catalog entries omitted compat metadata. + const modelWithUsageCompat = { + ...model, + compat: { + ...(model.compat && typeof model.compat === "object" ? model.compat : {}), + supportsUsageInStreaming: true, + }, + }; + const stream = underlying(modelWithUsageCompat, context, options); + return stream instanceof Promise ? await stream : stream; + })(); + }; +} diff --git a/extensions/memory-core/src/memory/embeddings.ts b/extensions/memory-core/src/memory/embeddings.ts index 3b59fee2560..609d91c1f46 100644 --- a/extensions/memory-core/src/memory/embeddings.ts +++ b/extensions/memory-core/src/memory/embeddings.ts @@ -17,6 +17,7 @@ import { canAutoSelectLocal } from "./provider-adapters.js"; export { DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LMSTUDIO_EMBEDDING_MODEL, DEFAULT_LOCAL_MODEL, DEFAULT_MISTRAL_EMBEDDING_MODEL, DEFAULT_OLLAMA_EMBEDDING_MODEL, diff --git a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts index 95c36b0acf4..b2c97396fb8 100644 --- a/extensions/memory-core/src/memory/manager.mistral-provider.test.ts +++ b/extensions/memory-core/src/memory/manager.mistral-provider.test.ts @@ -11,10 +11,15 @@ import { } from "./manager-provider-state.js"; const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text"; +const DEFAULT_LMSTUDIO_EMBEDDING_MODEL = "text-embedding-nomic-embed-text-v1.5"; vi.mock("./embeddings.js", () => ({ resolveEmbeddingProviderFallbackModel: (providerId: string, fallbackSourceModel: string) => - providerId === "ollama" ? DEFAULT_OLLAMA_EMBEDDING_MODEL : fallbackSourceModel, + providerId === "ollama" + ? DEFAULT_OLLAMA_EMBEDDING_MODEL + : providerId === "lmstudio" + ? DEFAULT_LMSTUDIO_EMBEDDING_MODEL + : fallbackSourceModel, })); type EmbeddingProvider = { @@ -40,7 +45,7 @@ function createProvider(id: string): EmbeddingProvider { function createSettings(params: { provider: "openai" | "mistral"; - fallback?: "none" | "mistral" | "ollama"; + fallback?: "none" | "mistral" | "ollama" | "lmstudio"; }): ResolvedMemorySearchConfig { return { provider: params.provider, @@ -130,4 +135,16 @@ describe("memory manager mistral provider wiring", () => { expect(request.model).toBe("gemini-embedding-2-preview"); expect(request.outputDimensionality).toBe(1536); }); + + it("uses default lmstudio model when activating lmstudio fallback", async () => { + const request = resolveMemoryFallbackProviderRequest({ + cfg: {} as OpenClawConfig, + settings: createSettings({ provider: "openai", fallback: "lmstudio" }), + currentProviderId: "openai", + }); + + expect(request?.provider).toBe("lmstudio"); + expect(request?.model).toBe(DEFAULT_LMSTUDIO_EMBEDDING_MODEL); + expect(request?.fallback).toBe("none"); + }); }); diff --git a/extensions/memory-core/src/memory/provider-adapters.ts b/extensions/memory-core/src/memory/provider-adapters.ts index b15c3e1f570..62803e95faa 100644 --- a/extensions/memory-core/src/memory/provider-adapters.ts +++ b/extensions/memory-core/src/memory/provider-adapters.ts @@ -1,6 +1,7 @@ import fsSync from "node:fs"; import { DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LMSTUDIO_EMBEDDING_MODEL, DEFAULT_LOCAL_MODEL, DEFAULT_MISTRAL_EMBEDDING_MODEL, DEFAULT_OPENAI_EMBEDDING_MODEL, @@ -8,6 +9,7 @@ import { OPENAI_BATCH_ENDPOINT, buildGeminiEmbeddingRequest, createGeminiEmbeddingProvider, + createLmstudioEmbeddingProvider, createLocalEmbeddingProvider, createMistralEmbeddingProvider, createOpenAiEmbeddingProvider, @@ -288,6 +290,31 @@ const mistralAdapter: MemoryEmbeddingProviderAdapter = { }, }; +const lmstudioAdapter: MemoryEmbeddingProviderAdapter = { + id: "lmstudio", + defaultModel: DEFAULT_LMSTUDIO_EMBEDDING_MODEL, + transport: "remote", + create: async (options) => { + const { provider, client } = await createLmstudioEmbeddingProvider({ + ...options, + provider: "lmstudio", + fallback: "none", + }); + return { + provider, + runtime: { + id: "lmstudio", + cacheKeyData: { + provider: "lmstudio", + baseUrl: client.baseUrl, + model: client.model, + headers: sanitizeHeaders(client.headers, ["authorization"]), + }, + }, + }; + }, +}; + const localAdapter: MemoryEmbeddingProviderAdapter = { id: "local", defaultModel: DEFAULT_LOCAL_MODEL, @@ -320,6 +347,7 @@ export const builtinMemoryEmbeddingProviderAdapters = [ geminiAdapter, voyageAdapter, mistralAdapter, + lmstudioAdapter, ] as const; const builtinMemoryEmbeddingProviderAdapterById = new Map( @@ -378,6 +406,7 @@ export function listBuiltinAutoSelectMemoryEmbeddingProviderDoctorMetadata(): Ar export { DEFAULT_GEMINI_EMBEDDING_MODEL, + DEFAULT_LMSTUDIO_EMBEDDING_MODEL, DEFAULT_LOCAL_MODEL, DEFAULT_MISTRAL_EMBEDDING_MODEL, DEFAULT_OPENAI_EMBEDDING_MODEL, diff --git a/package.json b/package.json index 649a1240e91..5e89c370eea 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,14 @@ "types": "./dist/plugin-sdk/core.d.ts", "default": "./dist/plugin-sdk/core.js" }, + "./plugin-sdk/lmstudio": { + "types": "./dist/plugin-sdk/lmstudio.d.ts", + "default": "./dist/plugin-sdk/lmstudio.js" + }, + "./plugin-sdk/lmstudio-runtime": { + "types": "./dist/plugin-sdk/lmstudio-runtime.d.ts", + "default": "./dist/plugin-sdk/lmstudio-runtime.js" + }, "./plugin-sdk/provider-setup": { "types": "./dist/plugin-sdk/provider-setup.d.ts", "default": "./dist/plugin-sdk/provider-setup.js" diff --git a/packages/memory-host-sdk/src/host/embeddings-lmstudio.ts b/packages/memory-host-sdk/src/host/embeddings-lmstudio.ts new file mode 100644 index 00000000000..99cc6475868 --- /dev/null +++ b/packages/memory-host-sdk/src/host/embeddings-lmstudio.ts @@ -0,0 +1 @@ +export * from "../../../../src/memory-host-sdk/host/embeddings-lmstudio.js"; diff --git a/packages/memory-host-sdk/src/host/embeddings.ts b/packages/memory-host-sdk/src/host/embeddings.ts index 5e26f54ddde..5deab0a591d 100644 --- a/packages/memory-host-sdk/src/host/embeddings.ts +++ b/packages/memory-host-sdk/src/host/embeddings.ts @@ -16,6 +16,10 @@ import { type GeminiEmbeddingClient, type GeminiTaskType, } from "./embeddings-gemini.js"; +import { + createLmstudioEmbeddingProvider, + type LmstudioEmbeddingClient, +} from "./embeddings-lmstudio.js"; import { createMistralEmbeddingProvider, type MistralEmbeddingClient, @@ -26,6 +30,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"; @@ -47,15 +52,16 @@ export type EmbeddingProviderId = | "gemini" | "voyage" | "mistral" - | "ollama" - | "bedrock"; + | "bedrock" + | "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. -// Bedrock is included when AWS credentials are detected. +// LM Studio and Ollama are intentionally excluded here so that "auto" mode does not +// implicitly assume either instance is available. +// Bedrock is handled separately when AWS credentials are detected. const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const; export type EmbeddingProviderResult = { @@ -68,8 +74,9 @@ export type EmbeddingProviderResult = { gemini?: GeminiEmbeddingClient; voyage?: VoyageEmbeddingClient; mistral?: MistralEmbeddingClient; - ollama?: OllamaEmbeddingClient; bedrock?: BedrockEmbeddingClient; + lmstudio?: LmstudioEmbeddingClient; + ollama?: OllamaEmbeddingClient; }; export type EmbeddingProviderOptions = { @@ -191,6 +198,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 }; @@ -304,7 +315,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/pnpm-lock.yaml b/pnpm-lock.yaml index ee54d97bdfc..fc271a92a9c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -703,6 +703,8 @@ importers: specifier: workspace:* version: link:../../packages/plugin-sdk + extensions/lmstudio: {} + extensions/lobster: dependencies: '@clawdbot/lobster': diff --git a/scripts/lib/bundled-runtime-sidecar-paths.json b/scripts/lib/bundled-runtime-sidecar-paths.json index 989d44602e4..9d4e3264a5a 100644 --- a/scripts/lib/bundled-runtime-sidecar-paths.json +++ b/scripts/lib/bundled-runtime-sidecar-paths.json @@ -11,6 +11,7 @@ "dist/extensions/imessage/runtime-api.js", "dist/extensions/irc/runtime-api.js", "dist/extensions/line/runtime-api.js", + "dist/extensions/lmstudio/runtime-api.js", "dist/extensions/lobster/runtime-api.js", "dist/extensions/matrix/helper-api.js", "dist/extensions/matrix/runtime-api.js", diff --git a/scripts/lib/plugin-sdk-entrypoints.json b/scripts/lib/plugin-sdk-entrypoints.json index af7d090f563..3bf130d9277 100644 --- a/scripts/lib/plugin-sdk-entrypoints.json +++ b/scripts/lib/plugin-sdk-entrypoints.json @@ -1,6 +1,8 @@ [ "index", "core", + "lmstudio", + "lmstudio-runtime", "provider-setup", "sandbox", "self-hosted-provider-setup", diff --git a/src/agents/auth-profiles/profiles.ts b/src/agents/auth-profiles/profiles.ts index 9d562bafa10..9ad9e3ae8e9 100644 --- a/src/agents/auth-profiles/profiles.ts +++ b/src/agents/auth-profiles/profiles.ts @@ -81,6 +81,49 @@ export async function upsertAuthProfileWithLock(params: { }); } +export async function removeProviderAuthProfilesWithLock(params: { + provider: string; + agentDir?: string; +}): Promise { + const providerKey = resolveProviderIdForAuth(params.provider); + const storeOrderKey = normalizeProviderId(params.provider); + return await updateAuthProfileStoreWithLock({ + agentDir: params.agentDir, + updater: (store) => { + const profileIds = listProfilesForProvider(store, params.provider); + let changed = false; + for (const profileId of profileIds) { + if (store.profiles[profileId]) { + delete store.profiles[profileId]; + changed = true; + } + if (store.usageStats?.[profileId]) { + delete store.usageStats[profileId]; + changed = true; + } + } + if (store.order?.[storeOrderKey]) { + delete store.order[storeOrderKey]; + changed = true; + if (Object.keys(store.order).length === 0) { + store.order = undefined; + } + } + if (store.lastGood?.[providerKey]) { + delete store.lastGood[providerKey]; + changed = true; + if (Object.keys(store.lastGood).length === 0) { + store.lastGood = undefined; + } + } + if (store.usageStats && Object.keys(store.usageStats).length === 0) { + store.usageStats = undefined; + } + return changed; + }, + }); +} + export function listProfilesForProvider(store: AuthProfileStore, provider: string): string[] { const providerKey = resolveProviderIdForAuth(provider); return Object.entries(store.profiles) diff --git a/src/agents/memory-search.test.ts b/src/agents/memory-search.test.ts index 8402ddee392..3ee708f0c34 100644 --- a/src/agents/memory-search.test.ts +++ b/src/agents/memory-search.test.ts @@ -46,6 +46,12 @@ function registerBaseMemoryEmbeddingProviders(options?: { includeGemini?: boolea transport: "remote", create: async () => ({ provider: null }), }); + registerMemoryEmbeddingProvider({ + id: "lmstudio", + defaultModel: "text-embedding-nomic-embed-text-v1.5", + transport: "remote", + create: async () => ({ provider: null }), + }); registerMemoryEmbeddingProvider({ id: "ollama", defaultModel: "nomic-embed-text", @@ -442,6 +448,13 @@ describe("memory search config", () => { expect(resolved?.model).toBe("mistral-embed"); }); + 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("includes remote defaults and model default for ollama without overrides", () => { const cfg = configWithDefaultProvider("ollama"); const resolved = resolveMemorySearchConfig(cfg, "main"); diff --git a/src/agents/models-config.providers.auth-provenance.test.ts b/src/agents/models-config.providers.auth-provenance.test.ts index 0f8f4b27093..792375a282f 100644 --- a/src/agents/models-config.providers.auth-provenance.test.ts +++ b/src/agents/models-config.providers.auth-provenance.test.ts @@ -9,6 +9,7 @@ type ProviderRuntimeModule = typeof import("../plugins/provider-runtime.js"); let NON_ENV_SECRETREF_MARKER: typeof import("./model-auth-markers.js").NON_ENV_SECRETREF_MARKER; let MINIMAX_OAUTH_MARKER: typeof import("./model-auth-markers.js").MINIMAX_OAUTH_MARKER; +let CUSTOM_LOCAL_AUTH_MARKER: typeof import("./model-auth-markers.js").CUSTOM_LOCAL_AUTH_MARKER; let resolveApiKeyFromCredential: typeof import("./models-config.providers.secrets.js").resolveApiKeyFromCredential; let createProviderAuthResolver: typeof import("./models-config.providers.secrets.js").createProviderAuthResolver; let mockedResolveProviderSyntheticAuthWithPlugin: ReturnType< @@ -26,6 +27,7 @@ async function loadProviderAuthModules() { mockedResolveProviderSyntheticAuthWithPlugin = vi.mocked( providerRuntimeModule.resolveProviderSyntheticAuthWithPlugin, ); + CUSTOM_LOCAL_AUTH_MARKER = markersModule.CUSTOM_LOCAL_AUTH_MARKER; NON_ENV_SECRETREF_MARKER = markersModule.NON_ENV_SECRETREF_MARKER; MINIMAX_OAUTH_MARKER = markersModule.MINIMAX_OAUTH_MARKER; resolveApiKeyFromCredential = secretsModule.resolveApiKeyFromCredential; @@ -162,4 +164,37 @@ describe("models-config provider auth provenance", () => { source: "none", }); }); + + it("preserves shared non-secret synthetic auth markers from provider hooks", () => { + mockedResolveProviderSyntheticAuthWithPlugin.mockReturnValue({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + mode: "api-key", + source: "test plugin", + }); + const auth = createProviderAuthResolver( + {} as NodeJS.ProcessEnv, + { + version: 1, + profiles: {}, + }, + { + plugins: { + entries: { + lmstudio: { + config: { + models: [{ id: "qwen/qwen3.5-9b" }], + }, + }, + }, + }, + }, + ); + + expect(auth("lmstudio")).toEqual({ + apiKey: CUSTOM_LOCAL_AUTH_MARKER, + discoveryApiKey: undefined, + mode: "api_key", + source: "none", + }); + }); }); diff --git a/src/agents/models-config.providers.normalize-keys.test.ts b/src/agents/models-config.providers.normalize-keys.test.ts index a70875f7f63..1e0e6cb15b6 100644 --- a/src/agents/models-config.providers.normalize-keys.test.ts +++ b/src/agents/models-config.providers.normalize-keys.test.ts @@ -8,11 +8,17 @@ import { normalizeProviders } from "./models-config.providers.normalize.js"; import { resolveApiKeyFromProfiles } from "./models-config.providers.secret-helpers.js"; import { enforceSourceManagedProviderSecrets } from "./models-config.providers.source-managed.js"; -vi.mock("./models-config.providers.policy.runtime.js", () => ({ - applyProviderNativeStreamingUsagePolicy: () => undefined, - normalizeProviderConfigPolicy: () => undefined, - resolveProviderConfigApiKeyPolicy: () => undefined, -})); +vi.mock("./models-config.providers.policy.runtime.js", async () => { + const { normalizeLmstudioProviderConfig } = await vi.importActual< + typeof import("../plugin-sdk/lmstudio-runtime.js") + >("../plugin-sdk/lmstudio-runtime.js"); + return { + applyProviderNativeStreamingUsagePolicy: () => undefined, + normalizeProviderConfigPolicy: (providerKey: string, provider: unknown) => + providerKey === "lmstudio" ? normalizeLmstudioProviderConfig(provider as never) : undefined, + resolveProviderConfigApiKeyPolicy: () => undefined, + }; +}); describe("normalizeProviders", () => { const createModel = ( @@ -269,4 +275,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/commands/doctor-memory-search.test.ts b/src/commands/doctor-memory-search.test.ts index 1e6a3f42dde..3aeab68186e 100644 --- a/src/commands/doctor-memory-search.test.ts +++ b/src/commands/doctor-memory-search.test.ts @@ -344,6 +344,53 @@ describe("noteMemorySearchHealth", () => { expect(note).not.toHaveBeenCalled(); }); + it("does not warn for lmstudio when gateway probe is ready", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg, { + gatewayMemoryProbe: { checked: true, ready: true }, + }); + + expect(note).not.toHaveBeenCalled(); + }); + + it("warns when lmstudio gateway probe reports embeddings are not ready", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg, { + gatewayMemoryProbe: { checked: true, ready: false, error: "LM API token missing" }, + }); + + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain('provider "lmstudio" is configured'); + expect(message).toContain("embeddings are not ready"); + }); + + it("warns when lmstudio gateway probe is unavailable", async () => { + resolveMemorySearchConfig.mockReturnValue({ + provider: "lmstudio", + local: {}, + remote: {}, + }); + + await noteMemorySearchHealth(cfg, { + gatewayMemoryProbe: { checked: false, ready: false }, + }); + + const message = String(note.mock.calls[0]?.[0] ?? ""); + expect(message).toContain('provider "lmstudio" is configured'); + expect(message).toContain("could not confirm embeddings are ready"); + expect(message).toContain("openclaw memory status --deep"); + }); + 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 4ca0eb9d2db..5f1a3de8853 100644 --- a/src/commands/doctor-memory-search.ts +++ b/src/commands/doctor-memory-search.ts @@ -315,6 +315,25 @@ export async function noteMemorySearchHealth( ); return; } + if (resolved.provider === "lmstudio") { + if (opts?.gatewayMemoryProbe?.checked && opts.gatewayMemoryProbe.ready) { + return; + } + const gatewayProbeWarning = buildGatewayProbeWarning(opts?.gatewayMemoryProbe); + note( + [ + gatewayProbeWarning + ? 'Memory search provider "lmstudio" is configured, but the gateway reports embeddings are not ready.' + : 'Memory search provider "lmstudio" is configured, but the gateway could not confirm embeddings are ready.', + gatewayProbeWarning, + `Verify: ${formatCliCommand("openclaw memory status --deep")}`, + ] + .filter(Boolean) + .join("\n"), + "Memory search", + ); + return; + } // Remote provider — check for API key if (hasRemoteApiKey || (await hasApiKeyForProvider(resolved.provider, cfg, agentDir))) { return; diff --git a/src/commands/onboard-non-interactive.provider-auth.test.ts b/src/commands/onboard-non-interactive.provider-auth.test.ts index 14e43e6dbb6..19e3315f305 100644 --- a/src/commands/onboard-non-interactive.provider-auth.test.ts +++ b/src/commands/onboard-non-interactive.provider-auth.test.ts @@ -419,6 +419,107 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async }; } + function resolveLmstudioDiscoveryUrl(baseUrl: string): string { + const normalized = baseUrl.trim().replace(/\/+$/u, ""); + if (normalized.endsWith("/api/v1")) { + return `${normalized}/models`; + } + if (normalized.endsWith("/v1")) { + return `${normalized.slice(0, -"/v1".length)}/api/v1/models`; + } + return `${normalized}/api/v1/models`; + } + + function extractLmstudioModelIds(payload: unknown): string[] { + if (!payload || typeof payload !== "object" || !("models" in payload)) { + return []; + } + const models = (payload as { models?: unknown }).models; + if (!Array.isArray(models)) { + return []; + } + return models + .map((entry) => { + if (!entry || typeof entry !== "object") { + return null; + } + const typedEntry = entry as { type?: unknown; key?: unknown }; + return { + type: normalizeText(typedEntry.type), + key: normalizeText(typedEntry.key), + }; + }) + .filter((entry): entry is { type: string; key: string } => Boolean(entry)) + .filter((entry) => entry.type.trim().toLowerCase() === "llm") + .map((entry) => entry.key.trim()) + .filter(Boolean); + } + + function createLmstudioChoice(): ChoiceHandler { + return { + providerId: "lmstudio", + label: "LM Studio", + runNonInteractive: async (ctx) => { + const baseUrl = normalizeText(ctx.opts.customBaseUrl) || "http://localhost:1234/v1"; + const lmstudioApiKey = normalizeText(ctx.opts.lmstudioApiKey); + const customApiKey = normalizeText(ctx.opts.customApiKey); + const resolved = await ctx.resolveApiKey({ + provider: "lmstudio", + flagValue: lmstudioApiKey || customApiKey, + flagName: lmstudioApiKey ? "--lmstudio-api-key" : "--custom-api-key", + envVar: "LM_API_TOKEN", + required: false, + }); + const resolvedOrSynthetic = resolved ?? { + key: "lmstudio-local", + source: "flag" as const, + }; + const credential = ctx.toApiKeyCredential({ + provider: "lmstudio", + resolved: resolvedOrSynthetic, + }); + if (!credential) { + return null; + } + upsertAuthProfile({ + profileId: "lmstudio:default", + credential: credential as never, + agentDir: ctx.agentDir, + }); + + const response = await fetch(resolveLmstudioDiscoveryUrl(baseUrl)); + const discoveredModelIds = extractLmstudioModelIds(await response.json()); + if (discoveredModelIds.length === 0) { + ctx.runtime.error(`No LM Studio LLM models were found at ${baseUrl}.`); + ctx.runtime.exit(1); + return null; + } + + const requestedModelId = normalizeText(ctx.opts.customModelId); + const selectedModelId = requestedModelId || discoveredModelIds[0]; + if (!discoveredModelIds.includes(selectedModelId)) { + ctx.runtime.error(`LM Studio model ${selectedModelId} was not found at ${baseUrl}.`); + ctx.runtime.exit(1); + return null; + } + + let next = applyAuthProfileConfig(ctx.config as never, { + profileId: "lmstudio:default", + provider: "lmstudio", + mode: "api_key", + }); + next = withProviderConfig(next, "lmstudio", { + baseUrl, + api: "openai-completions", + auth: "api-key", + apiKey: resolved ? "LM_API_TOKEN" : "lmstudio-local", + models: discoveredModelIds.map((id) => buildTestProviderModel(id)), + }); + return applyPrimaryModel(next as never, `lmstudio/${selectedModelId}`); + }, + }; + } + function createZaiChoice( choiceId: "zai-api-key" | "zai-coding-cn" | "zai-coding-global", ): ChoiceHandler { @@ -436,7 +537,10 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async return null; } if (resolved.source !== "profile") { - const credential = ctx.toApiKeyCredential({ provider: "zai", resolved }); + const credential = ctx.toApiKeyCredential({ + provider: "zai", + resolved, + }); if (!credential) { return null; } @@ -712,6 +816,7 @@ vi.mock("./onboard-non-interactive/local/auth-choice.plugin-providers.js", async modelPlaceholder: "Qwen/Qwen3-32B", }), ], + ["lmstudio", createLmstudioChoice()], [ "litellm-api-key", createApiKeyChoice({ @@ -912,7 +1017,9 @@ function expectZaiProbeCalls( expected: Array<{ url: string; modelId: string }>, ): void { const calls = ( - fetchMock as unknown as { mock: { calls: Array<[RequestInfo | URL, RequestInit?]> } } + fetchMock as unknown as { + mock: { calls: Array<[RequestInfo | URL, RequestInit?]> }; + } ).mock.calls; expect(calls).toHaveLength(expected.length); @@ -1224,7 +1331,12 @@ 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"); - await expectApiKeyProfile({ profileId: "xai:default", provider: "xai", key: "xai-test-key" }); + expect(cfg.agents?.defaults?.model?.primary).toBe("xai/grok-4"); + await expectApiKeyProfile({ + profileId: "xai:default", + provider: "xai", + key: "xai-test-key", + }); }); }); diff --git a/src/commands/onboard-types.ts b/src/commands/onboard-types.ts index f0aa2e6f895..68b57fa78be 100644 --- a/src/commands/onboard-types.ts +++ b/src/commands/onboard-types.ts @@ -58,6 +58,7 @@ export type OnboardOptions = OnboardDynamicProviderOptions & { cloudflareAiGatewayGatewayId?: string; customBaseUrl?: string; customApiKey?: string; + lmstudioApiKey?: string; customModelId?: string; customProviderId?: string; customCompatibility?: "openai" | "anthropic"; diff --git a/src/config/schema.base.generated.ts b/src/config/schema.base.generated.ts index 4e790b5aab7..993efc10163 100644 --- a/src/config/schema.base.generated.ts +++ b/src/config/schema.base.generated.ts @@ -3785,7 +3785,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", title: "Memory Search Provider", description: - 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "bedrock", "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", "bedrock", "lmstudio", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', }, remote: { type: "object", @@ -3926,7 +3926,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { type: "string", title: "Memory Search Fallback", description: - '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", "bedrock", "lmstudio", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', }, model: { type: "string", @@ -24622,7 +24622,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "agents.defaults.memorySearch.provider": { label: "Memory Search Provider", - help: 'Selects the embedding backend used to build/query memory vectors: "openai", "gemini", "voyage", "mistral", "bedrock", "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", "bedrock", "lmstudio", "ollama", or "local". Keep your most reliable provider here and configure fallback for resilience.', tags: ["advanced"], }, "agents.defaults.memorySearch.remote.baseUrl": { @@ -24678,7 +24678,7 @@ export const GENERATED_BASE_CONFIG_SCHEMA: BaseConfigSchemaResponse = { }, "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", "bedrock", "lmstudio", "ollama", "local", or "none". Set a real fallback for production reliability; use "none" only if you prefer explicit failures.', tags: ["reliability"], }, "agents.defaults.memorySearch.local.modelPath": { diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index 1f9feb00cab..02a44845458 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -899,7 +899,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", "bedrock", "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", "bedrock", "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": @@ -923,7 +923,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", "bedrock", "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/memory-host-sdk/engine-embeddings.ts b/src/memory-host-sdk/engine-embeddings.ts index 54cef90adca..eef160b2f63 100644 --- a/src/memory-host-sdk/engine-embeddings.ts +++ b/src/memory-host-sdk/engine-embeddings.ts @@ -21,6 +21,11 @@ export { DEFAULT_GEMINI_EMBEDDING_MODEL, buildGeminiEmbeddingRequest, } from "./host/embeddings-gemini.js"; +export { + createLmstudioEmbeddingProvider, + DEFAULT_LMSTUDIO_EMBEDDING_MODEL, +} from "./host/embeddings-lmstudio.js"; +export type { LmstudioEmbeddingClient } from "./host/embeddings-lmstudio.js"; export { createMistralEmbeddingProvider, DEFAULT_MISTRAL_EMBEDDING_MODEL, diff --git a/src/memory-host-sdk/host/embeddings-lmstudio.test.ts b/src/memory-host-sdk/host/embeddings-lmstudio.test.ts new file mode 100644 index 00000000000..87250cc977b --- /dev/null +++ b/src/memory-host-sdk/host/embeddings-lmstudio.test.ts @@ -0,0 +1,362 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../../config/config.js"; + +const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn()); +const resolveLmstudioRuntimeApiKeyMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../plugin-sdk/lmstudio-runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureLmstudioModelLoaded: (...args: unknown[]) => ensureLmstudioModelLoadedMock(...args), + resolveLmstudioRuntimeApiKey: (...args: unknown[]) => resolveLmstudioRuntimeApiKeyMock(...args), + }; +}); + +let createLmstudioEmbeddingProvider: typeof import("./embeddings-lmstudio.js").createLmstudioEmbeddingProvider; + +describe("embeddings-lmstudio", () => { + const originalFetch = globalThis.fetch; + const jsonResponse = (embedding: number[]) => + new Response( + JSON.stringify({ + data: [{ embedding }], + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ); + + function mockEmbeddingFetch(embedding: number[]) { + const fetchMock = vi.fn(); + fetchMock.mockResolvedValue(jsonResponse(embedding)); + globalThis.fetch = fetchMock as unknown as typeof fetch; + return fetchMock; + } + + beforeEach(async () => { + vi.resetModules(); + ({ createLmstudioEmbeddingProvider } = await import("./embeddings-lmstudio.js")); + ensureLmstudioModelLoadedMock.mockReset(); + resolveLmstudioRuntimeApiKeyMock.mockReset(); + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("embeds against inference base and warms model with resolved key", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-lmstudio-key"); + + const fetchMock = mockEmbeddingFetch([0.1, 0.2]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234/api/v1/", + headers: { "X-Provider": "provider" }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "lmstudio/text-embedding-nomic-embed-text-v1.5", + fallback: "none", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + "Content-Type": "application/json", + Authorization: "Bearer profile-lmstudio-key", + "X-Provider": "provider", + }), + }), + ); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-lmstudio-key", + headers: { + "X-Provider": "provider", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + }); + + it("uses memorySearch remote overrides for primary lmstudio", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + headers: { + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "", + fallback: "none", + remote: { + baseUrl: "http://localhost:9999", + apiKey: "remote-lmstudio-key", + headers: { + "X-Provider": "remote", + "X-Remote-Only": "from-remote", + }, + }, + }); + + await provider.embedBatch(["one", "two"]); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:9999/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer remote-lmstudio-key", + "X-Provider": "remote", + "X-Config-Only": "from-provider", + "X-Remote-Only": "from-remote", + }), + }), + ); + 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("preserves remote Authorization header auth for primary lmstudio", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("stale-profile-key"); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + headers: { + "X-Provider": "provider", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "lmstudio", + model: "", + fallback: "none", + remote: { + baseUrl: "http://localhost:9999", + headers: { + Authorization: "Bearer remote-proxy-token", + "X-Remote-Only": "from-remote", + }, + }, + }); + + await provider.embedBatch(["one", "two"]); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:9999/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer remote-proxy-token", + "X-Provider": "provider", + "X-Remote-Only": "from-remote", + }), + }), + ); + expect(resolveLmstudioRuntimeApiKeyMock).not.toHaveBeenCalled(); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:9999/v1", + apiKey: undefined, + headers: { + "X-Provider": "provider", + Authorization: "Bearer remote-proxy-token", + "X-Remote-Only": "from-remote", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + }); + + it("ignores memorySearch remote overrides for fallback lmstudio activation", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + headers: { + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "openai", + model: "", + fallback: "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", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer profile-key", + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }), + }), + ); + const callHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(callHeaders["X-Remote-Only"]).toBeUndefined(); + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalled(); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledWith({ + baseUrl: "http://localhost:1234/v1", + apiKey: "profile-key", + headers: { + "X-Provider": "provider", + "X-Config-Only": "from-provider", + }, + ssrfPolicy: { allowedHostnames: ["localhost"] }, + modelKey: "text-embedding-nomic-embed-text-v1.5", + timeoutMs: 120_000, + }); + }); + + it("skips remote SecretRef resolution for fallback lmstudio activation", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("profile-key"); + + const fetchMock = mockEmbeddingFetch([1, 2, 3]); + + const { provider } = await createLmstudioEmbeddingProvider({ + config: { + models: { + providers: { + lmstudio: { + baseUrl: "http://localhost:1234", + headers: { + "X-Provider": "provider", + }, + models: [], + }, + }, + }, + } as OpenClawConfig, + provider: "openai", + model: "", + fallback: "lmstudio", + remote: { + baseUrl: "http://localhost:9999", + apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" }, + headers: { + "X-Remote-Only": "from-remote", + }, + }, + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer profile-key", + "X-Provider": "provider", + }), + }), + ); + const callHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Record; + expect(callHeaders["X-Remote-Only"]).toBeUndefined(); + expect(resolveLmstudioRuntimeApiKeyMock).toHaveBeenCalled(); + }); + + it("uses env-template-backed provider api keys in embedding requests", async () => { + ensureLmstudioModelLoadedMock.mockResolvedValue(undefined); + resolveLmstudioRuntimeApiKeyMock.mockResolvedValue("template-lmstudio-key"); + + const fetchMock = mockEmbeddingFetch([0.3, 0.4]); + + const { provider } = await 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", + }); + + await provider.embedQuery("hello"); + + expect(fetchMock).toHaveBeenCalledWith( + "http://localhost:1234/v1/embeddings", + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: "Bearer template-lmstudio-key", + }), + }), + ); + }); +}); diff --git a/src/memory-host-sdk/host/embeddings-lmstudio.ts b/src/memory-host-sdk/host/embeddings-lmstudio.ts new file mode 100644 index 00000000000..d80c4255f0b --- /dev/null +++ b/src/memory-host-sdk/host/embeddings-lmstudio.ts @@ -0,0 +1,146 @@ +import { formatErrorMessage } from "../../infra/errors.js"; +import type { SsrFPolicy } from "../../infra/net/ssrf.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; +import { + buildLmstudioAuthHeaders, + ensureLmstudioModelLoaded, + LMSTUDIO_DEFAULT_EMBEDDING_MODEL, + LMSTUDIO_PROVIDER_ID, + resolveLmstudioInferenceBase, + resolveLmstudioProviderHeaders, + resolveLmstudioRuntimeApiKey, +} from "../../plugin-sdk/lmstudio-runtime.js"; +import { normalizeEmbeddingModelWithPrefixes } from "./embeddings-model-normalize.js"; +import { createRemoteEmbeddingProvider } from "./embeddings-remote-provider.js"; +import type { EmbeddingProvider, EmbeddingProviderOptions } from "./embeddings.types.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/"], + }); +} + +function hasAuthorizationHeader(headers: Record | undefined): boolean { + if (!headers) { + return false; + } + return Object.entries(headers).some( + ([headerName, value]) => + headerName.trim().toLowerCase() === "authorization" && value.trim().length > 0, + ); +} + +/** Resolves API key (real or synthetic placeholder) from runtime/provider auth config. */ +async function resolveLmstudioApiKey( + options: EmbeddingProviderOptions, +): Promise { + try { + return await resolveLmstudioRuntimeApiKey({ + config: options.config, + agentDir: options.agentDir, + }); + } catch (error) { + // Embeddings can target local LM Studio instances that do not require auth. + if (/LM Studio API key is required/i.test(formatErrorMessage(error))) { + return undefined; + } + throw error; + } +} + +/** Creates the LM Studio embedding provider client and preloads the target model before return. */ +export async function createLmstudioEmbeddingProvider( + options: EmbeddingProviderOptions, +): Promise<{ provider: EmbeddingProvider; client: LmstudioEmbeddingClient }> { + const providerConfig = options.config.models?.providers?.lmstudio; + const providerBaseUrl = providerConfig?.baseUrl?.trim(); + const isFallbackActivation = options.fallback === "lmstudio" && options.provider !== "lmstudio"; + const remoteBaseUrl = options.remote?.baseUrl?.trim(); + const remoteApiKey = !isFallbackActivation + ? resolveMemorySecretInputString({ + value: options.remote?.apiKey, + path: "agents.*.memorySearch.remote.apiKey", + }) + : undefined; + // memorySearch.remote is shared across primary + fallback providers. + // Ignore it during fallback activation to avoid inheriting another provider's + // endpoint/headers/credentials when LM Studio activates as a fallback. + const baseUrlSource = !isFallbackActivation ? remoteBaseUrl : undefined; + const configuredBaseUrl = + baseUrlSource && baseUrlSource.length > 0 + ? baseUrlSource + : providerBaseUrl && providerBaseUrl.length > 0 + ? providerBaseUrl + : undefined; + const baseUrl = resolveLmstudioInferenceBase(configuredBaseUrl); + const model = normalizeLmstudioModel(options.model); + const providerHeaders = await resolveLmstudioProviderHeaders({ + config: options.config, + env: process.env, + headers: Object.assign( + {}, + providerConfig?.headers, + !isFallbackActivation ? options.remote?.headers : {}, + ), + }); + const apiKey = hasAuthorizationHeader(providerHeaders) + ? undefined + : !isFallbackActivation + ? remoteApiKey?.trim() || (await resolveLmstudioApiKey(options)) + : await resolveLmstudioApiKey(options); + const headerOverrides = Object.assign({}, providerHeaders); + const headers = + buildLmstudioAuthHeaders({ + apiKey, + json: true, + headers: headerOverrides, + }) ?? {}; + const ssrfPolicy = buildRemoteBaseUrlPolicy(baseUrl); + const client: LmstudioEmbeddingClient = { + baseUrl, + model, + headers, + ssrfPolicy, + }; + + try { + await ensureLmstudioModelLoaded({ + baseUrl, + apiKey, + headers: headerOverrides, + ssrfPolicy, + modelKey: model, + timeoutMs: 120_000, + }); + } catch (error) { + log.warn("lmstudio embeddings warmup failed; continuing without preload", { + baseUrl, + model, + error: formatErrorMessage(error), + }); + } + + return { + provider: createRemoteEmbeddingProvider({ + id: LMSTUDIO_PROVIDER_ID, + client, + errorPrefix: "lmstudio embeddings failed", + }), + client, + }; +} diff --git a/src/memory-host-sdk/host/embeddings.ts b/src/memory-host-sdk/host/embeddings.ts index b105acec839..a8ca24ed7a7 100644 --- a/src/memory-host-sdk/host/embeddings.ts +++ b/src/memory-host-sdk/host/embeddings.ts @@ -10,6 +10,10 @@ import { type BedrockEmbeddingClient, } from "./embeddings-bedrock.js"; import { createGeminiEmbeddingProvider, type GeminiEmbeddingClient } from "./embeddings-gemini.js"; +import { + createLmstudioEmbeddingProvider, + type LmstudioEmbeddingClient, +} from "./embeddings-lmstudio.js"; import { createMistralEmbeddingProvider, type MistralEmbeddingClient, @@ -28,6 +32,7 @@ import type { 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"; @@ -43,9 +48,9 @@ export type { } from "./embeddings.types.js"; // 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. -// Bedrock is included when AWS credentials are detected. +// LM Studio and Ollama are intentionally excluded here so that "auto" mode does not +// implicitly assume either instance is available. +// Bedrock is handled separately when AWS credentials are detected. const REMOTE_EMBEDDING_PROVIDER_IDS = ["openai", "gemini", "voyage", "mistral"] as const; export type EmbeddingProviderResult = { @@ -60,6 +65,7 @@ export type EmbeddingProviderResult = { mistral?: MistralEmbeddingClient; ollama?: OllamaEmbeddingClient; bedrock?: BedrockEmbeddingClient; + lmstudio?: LmstudioEmbeddingClient; }; export const DEFAULT_LOCAL_MODEL = @@ -160,6 +166,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 }; diff --git a/src/memory-host-sdk/host/embeddings.types.ts b/src/memory-host-sdk/host/embeddings.types.ts index f460d14f665..297db0ecdc3 100644 --- a/src/memory-host-sdk/host/embeddings.types.ts +++ b/src/memory-host-sdk/host/embeddings.types.ts @@ -17,6 +17,7 @@ export type EmbeddingProviderId = | "gemini" | "voyage" | "mistral" + | "lmstudio" | "ollama" | "bedrock"; diff --git a/src/plugin-sdk/lmstudio-runtime.test.ts b/src/plugin-sdk/lmstudio-runtime.test.ts new file mode 100644 index 00000000000..8ca71ccce93 --- /dev/null +++ b/src/plugin-sdk/lmstudio-runtime.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const loadBundledPluginPublicSurfaceModuleSync = vi.hoisted(() => vi.fn()); + +vi.mock("./facade-runtime.js", async () => { + const actual = await vi.importActual("./facade-runtime.js"); + return { + ...actual, + loadBundledPluginPublicSurfaceModuleSync, + }; +}); + +describe("plugin-sdk lmstudio-runtime", () => { + beforeEach(() => { + loadBundledPluginPublicSurfaceModuleSync.mockReset(); + }); + + it("keeps the lmstudio runtime facade cold until a helper is used", async () => { + const module = await import("./lmstudio-runtime.js"); + + expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + expect(module.LMSTUDIO_PROVIDER_ID).toBe("lmstudio"); + expect(module.LMSTUDIO_DEFAULT_EMBEDDING_MODEL).toBe("text-embedding-nomic-embed-text-v1.5"); + expect(loadBundledPluginPublicSurfaceModuleSync).not.toHaveBeenCalled(); + }); + + it("delegates lmstudio helpers through the bundled runtime facade", async () => { + const resolveLmstudioInferenceBase = vi.fn().mockReturnValue("http://localhost:1234/v1"); + loadBundledPluginPublicSurfaceModuleSync.mockReturnValue({ + resolveLmstudioInferenceBase, + }); + + const module = await import("./lmstudio-runtime.js"); + + expect(module.resolveLmstudioInferenceBase("http://localhost:1234/api/v1/")).toBe( + "http://localhost:1234/v1", + ); + expect(loadBundledPluginPublicSurfaceModuleSync).toHaveBeenCalledWith({ + dirName: "lmstudio", + artifactBasename: "runtime-api.js", + }); + expect(resolveLmstudioInferenceBase).toHaveBeenCalledWith("http://localhost:1234/api/v1/"); + }); +}); diff --git a/src/plugin-sdk/lmstudio-runtime.ts b/src/plugin-sdk/lmstudio-runtime.ts new file mode 100644 index 00000000000..10e743b8cff --- /dev/null +++ b/src/plugin-sdk/lmstudio-runtime.ts @@ -0,0 +1,69 @@ +// Manual facade. Keep loader boundary explicit. +type FacadeModule = typeof import("@openclaw/lmstudio/runtime-api.js"); +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +function loadFacadeModule(): FacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "lmstudio", + artifactBasename: "runtime-api.js", + }); +} + +export type LmstudioModelWire = Parameters[0]; +export type LmstudioModelBase = Exclude, null>; + +// Keep defaults inline so importing the runtime facade stays cold until a helper +// is actually used. These values are part of the public LM Studio contract. +export const LMSTUDIO_DEFAULT_BASE_URL: FacadeModule["LMSTUDIO_DEFAULT_BASE_URL"] = + "http://localhost:1234"; +export const LMSTUDIO_DEFAULT_INFERENCE_BASE_URL: FacadeModule["LMSTUDIO_DEFAULT_INFERENCE_BASE_URL"] = `${LMSTUDIO_DEFAULT_BASE_URL}/v1`; +export const LMSTUDIO_DEFAULT_EMBEDDING_MODEL: FacadeModule["LMSTUDIO_DEFAULT_EMBEDDING_MODEL"] = + "text-embedding-nomic-embed-text-v1.5"; +export const LMSTUDIO_PROVIDER_LABEL: FacadeModule["LMSTUDIO_PROVIDER_LABEL"] = "LM Studio"; +export const LMSTUDIO_DEFAULT_API_KEY_ENV_VAR: FacadeModule["LMSTUDIO_DEFAULT_API_KEY_ENV_VAR"] = + "LM_API_TOKEN"; +export const LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER: FacadeModule["LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER"] = + "lmstudio-local"; +export const LMSTUDIO_MODEL_PLACEHOLDER: FacadeModule["LMSTUDIO_MODEL_PLACEHOLDER"] = + "model-key-from-api-v1-models"; +export const LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH: FacadeModule["LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH"] = 64000; +export const LMSTUDIO_DEFAULT_MODEL_ID: FacadeModule["LMSTUDIO_DEFAULT_MODEL_ID"] = + "qwen/qwen3.5-9b"; +export const LMSTUDIO_PROVIDER_ID: FacadeModule["LMSTUDIO_PROVIDER_ID"] = "lmstudio"; + +export const resolveLmstudioReasoningCapability: FacadeModule["resolveLmstudioReasoningCapability"] = + createLazyFacadeValue("resolveLmstudioReasoningCapability"); +export const resolveLoadedContextWindow: FacadeModule["resolveLoadedContextWindow"] = + createLazyFacadeValue("resolveLoadedContextWindow"); +export const resolveLmstudioServerBase: FacadeModule["resolveLmstudioServerBase"] = + createLazyFacadeValue("resolveLmstudioServerBase"); +export const resolveLmstudioInferenceBase: FacadeModule["resolveLmstudioInferenceBase"] = + createLazyFacadeValue("resolveLmstudioInferenceBase"); +export const normalizeLmstudioProviderConfig: FacadeModule["normalizeLmstudioProviderConfig"] = + createLazyFacadeValue("normalizeLmstudioProviderConfig"); +export const fetchLmstudioModels: FacadeModule["fetchLmstudioModels"] = + createLazyFacadeValue("fetchLmstudioModels"); +export const mapLmstudioWireEntry: FacadeModule["mapLmstudioWireEntry"] = + createLazyFacadeValue("mapLmstudioWireEntry"); +export const discoverLmstudioModels: FacadeModule["discoverLmstudioModels"] = + createLazyFacadeValue("discoverLmstudioModels"); +export const ensureLmstudioModelLoaded: FacadeModule["ensureLmstudioModelLoaded"] = + createLazyFacadeValue("ensureLmstudioModelLoaded"); +export const buildLmstudioAuthHeaders: FacadeModule["buildLmstudioAuthHeaders"] = + createLazyFacadeValue("buildLmstudioAuthHeaders"); +export const resolveLmstudioConfiguredApiKey: FacadeModule["resolveLmstudioConfiguredApiKey"] = + createLazyFacadeValue("resolveLmstudioConfiguredApiKey"); +export const resolveLmstudioProviderHeaders: FacadeModule["resolveLmstudioProviderHeaders"] = + createLazyFacadeValue("resolveLmstudioProviderHeaders"); +export const resolveLmstudioRuntimeApiKey: FacadeModule["resolveLmstudioRuntimeApiKey"] = + createLazyFacadeValue("resolveLmstudioRuntimeApiKey"); + +function createLazyFacadeValue(key: K): FacadeModule[K] { + return ((...args: unknown[]) => { + const value = loadFacadeModule()[key]; + if (typeof value !== "function") { + return value; + } + return (value as (...innerArgs: unknown[]) => unknown)(...args); + }) as FacadeModule[K]; +} diff --git a/src/plugin-sdk/lmstudio.ts b/src/plugin-sdk/lmstudio.ts new file mode 100644 index 00000000000..4b102b08606 --- /dev/null +++ b/src/plugin-sdk/lmstudio.ts @@ -0,0 +1,67 @@ +export type { + OpenClawPluginApi, + ProviderAuthContext, + ProviderAuthMethodNonInteractiveContext, + ProviderAuthResult, + ProviderCatalogContext, + ProviderDiscoveryContext, + ProviderPrepareDynamicModelContext, + ProviderRuntimeModel, +} from "../plugins/types.js"; + +export type { LmstudioModelBase, LmstudioModelWire } from "./lmstudio-runtime.js"; +export { + LMSTUDIO_DEFAULT_API_KEY_ENV_VAR, + LMSTUDIO_DEFAULT_BASE_URL, + LMSTUDIO_DEFAULT_EMBEDDING_MODEL, + LMSTUDIO_DEFAULT_INFERENCE_BASE_URL, + LMSTUDIO_DEFAULT_LOAD_CONTEXT_LENGTH, + LMSTUDIO_DEFAULT_MODEL_ID, + LMSTUDIO_LOCAL_API_KEY_PLACEHOLDER, + LMSTUDIO_MODEL_PLACEHOLDER, + LMSTUDIO_PROVIDER_ID, + LMSTUDIO_PROVIDER_LABEL, + buildLmstudioAuthHeaders, + discoverLmstudioModels, + ensureLmstudioModelLoaded, + fetchLmstudioModels, + mapLmstudioWireEntry, + normalizeLmstudioProviderConfig, + resolveLoadedContextWindow, + resolveLmstudioConfiguredApiKey, + resolveLmstudioInferenceBase, + resolveLmstudioProviderHeaders, + resolveLmstudioReasoningCapability, + resolveLmstudioRuntimeApiKey, + resolveLmstudioServerBase, +} from "./lmstudio-runtime.js"; + +type FacadeModule = typeof import("@openclaw/lmstudio/api.js"); +import { loadBundledPluginPublicSurfaceModuleSync } from "./facade-runtime.js"; + +function loadFacadeModule(): FacadeModule { + return loadBundledPluginPublicSurfaceModuleSync({ + dirName: "lmstudio", + artifactBasename: "api.js", + }); +} + +export const promptAndConfigureLmstudioInteractive: FacadeModule["promptAndConfigureLmstudioInteractive"] = + ((...args) => + loadFacadeModule().promptAndConfigureLmstudioInteractive( + ...args, + )) as FacadeModule["promptAndConfigureLmstudioInteractive"]; +export const configureLmstudioNonInteractive: FacadeModule["configureLmstudioNonInteractive"] = (( + ...args +) => + loadFacadeModule().configureLmstudioNonInteractive( + ...args, + )) as FacadeModule["configureLmstudioNonInteractive"]; +export const discoverLmstudioProvider: FacadeModule["discoverLmstudioProvider"] = ((...args) => + loadFacadeModule().discoverLmstudioProvider(...args)) as FacadeModule["discoverLmstudioProvider"]; +export const prepareLmstudioDynamicModels: FacadeModule["prepareLmstudioDynamicModels"] = (( + ...args +) => + loadFacadeModule().prepareLmstudioDynamicModels( + ...args, + )) as FacadeModule["prepareLmstudioDynamicModels"]; diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index a0b68a39b8a..e1b3de8fa1e 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -6,6 +6,7 @@ import { resolveEnvApiKey } from "../agents/model-auth-env.js"; export type { OpenClawConfig } from "../config/config.js"; export type { SecretInput } from "../config/types.secrets.js"; +export type { SecretInputMode } from "../plugins/provider-auth-types.js"; export type { ProviderAuthResult } from "../plugins/types.js"; export type { ProviderAuthContext } from "../plugins/types.js"; export type { AuthProfileStore, OAuthCredential } from "../agents/auth-profiles/types.js"; @@ -14,6 +15,7 @@ export { CLAUDE_CLI_PROFILE_ID, CODEX_CLI_PROFILE_ID } from "../agents/auth-prof export { ensureAuthProfileStore } from "../agents/auth-profiles/store.js"; export { listProfilesForProvider, + removeProviderAuthProfilesWithLock, upsertAuthProfile, upsertAuthProfileWithLock, } from "../agents/auth-profiles/profiles.js"; @@ -21,7 +23,9 @@ export { resolveEnvApiKey } from "../agents/model-auth-env.js"; export { readClaudeCliCredentialsCached } from "../agents/cli-credentials.js"; export { suggestOAuthProfileIdForLegacyDefault } from "../agents/auth-profiles/repair.js"; export { + CUSTOM_LOCAL_AUTH_MARKER, MINIMAX_OAUTH_MARKER, + isKnownEnvApiKeyMarker, isNonSecretApiKeyMarker, resolveOAuthApiKeyMarker, resolveNonEnvSecretRefApiKeyMarker, @@ -32,11 +36,13 @@ export { validateApiKeyInput, } from "../plugins/provider-auth-input.js"; export { + ensureApiKeyFromEnvOrPrompt, ensureApiKeyFromOptionEnvOrPrompt, normalizeSecretInputModeInput, promptSecretRefForSetup, resolveSecretInputModeForEnvSelection, } from "../plugins/provider-auth-input.js"; +export { normalizeApiKeyConfig } from "../agents/models-config.providers.secrets.js"; export { buildTokenProfileId, validateAnthropicSetupToken, @@ -50,7 +56,7 @@ export { type WriteOAuthCredentialsOptions, } from "../plugins/provider-auth-helpers.js"; export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth.js"; -export { coerceSecretRef } from "../config/types.secrets.js"; +export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; export { resolveRequiredHomeDir } from "../infra/home-dir.js"; export { diff --git a/src/plugin-sdk/provider-setup.ts b/src/plugin-sdk/provider-setup.ts index e7032655f57..d1d8f8b03ce 100644 --- a/src/plugin-sdk/provider-setup.ts +++ b/src/plugin-sdk/provider-setup.ts @@ -4,7 +4,10 @@ export type { ProviderAuthContext, ProviderAuthMethodNonInteractiveContext, ProviderAuthResult, + ProviderCatalogContext, ProviderDiscoveryContext, + ProviderPrepareDynamicModelContext, + ProviderRuntimeModel, } from "../plugins/types.js"; export { diff --git a/src/plugins/types.ts b/src/plugins/types.ts index e812334e016..6f1413b3f1b 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1710,7 +1710,9 @@ export type OpenClawPluginCommandDefinition = { * `default` applies to all native providers unless a provider-specific * override exists. */ - nativeProgressMessages?: Partial> & { default?: string }; + nativeProgressMessages?: Partial> & { + default?: string; + }; /** Description shown in /help and command menus */ description: string; /** Whether this command accepts arguments */ diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index f5b74612bf6..80b9f996207 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -13,6 +13,7 @@ type ResolveProviderPluginChoice = typeof import("../plugins/provider-auth-choice.runtime.js").resolveProviderPluginChoice; type ResolvePluginProvidersRuntime = typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginProviders; +type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); @@ -26,7 +27,7 @@ const resolvePluginProvidersRuntime = vi.hoisted(() => ); const warnIfModelConfigLooksOff = vi.hoisted(() => vi.fn(async () => {})); const applyPrimaryModel = vi.hoisted(() => vi.fn((cfg) => cfg)); -const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({ config: null, model: null }))); +const promptDefaultModel = vi.hoisted(() => vi.fn(async () => ({}))); const promptCustomApiConfig = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); const configureGatewayForSetup = vi.hoisted(() => vi.fn(async (args) => ({