diff --git a/README.md b/README.md
index b32a299d..2e3138db 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@
**AI that gets your everyday tasks done.** OpenWorker is an open-source AI coworker that lives on your desktop and delivers **finished work**, not just chat: a polished document, a Slack reply with the numbers, an updated calendar, a triaged inbox.
-It runs on your machine and doesn't lock you into any model: bring your own API key for OpenAI, Anthropic, Google, or an open-weight provider, or run fully local with Ollama. Your data leaves your machine only through the model and integrations *you* choose.
+It runs on your machine and doesn't lock you into any model: bring your own API key for OpenAI, Anthropic, Google, or an open-weight provider, or run fully local with Ollama or LM Studio. Your data leaves your machine only through the model and integrations *you* choose.
[](https://openworker.com)
@@ -20,7 +20,7 @@ It runs on your machine and doesn't lock you into any model: bring your own API
[**⬇ Windows 10/11 (x64)**](https://download.openworker.com/windows)
builds are not yet code-signed, so SmartScreen will warn; signing is in progress
-Open the app, add a model key (or point it at Ollama), and ask for something real.
+Open the app, add a model key (or point it at Ollama or LM Studio), and ask for something real.
## How it works
@@ -54,7 +54,7 @@ Under the hood:
Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box:
-**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.
+**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama** and **LM Studio**.
A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.
diff --git a/coworker/providers/capabilities.py b/coworker/providers/capabilities.py
index f096ff3a..e8b6c1b5 100644
--- a/coworker/providers/capabilities.py
+++ b/coworker/providers/capabilities.py
@@ -22,9 +22,10 @@ def capabilities_for(model: str) -> ModelCapabilities:
provider = model.split(":", 1)[0].lower() if ":" in model else ""
name = model.split(":", 1)[-1].lower() # strip a provider prefix if present
- # Ollama (local) models vary widely and many fake/mishandle parallel tool calls — assume
- # tools work (we only point at tool-capable models) but stay conservative otherwise.
- if provider == "ollama":
+ # Local servers (Ollama, LM Studio) host models that vary widely and many fake/mishandle
+ # parallel tool calls — assume tools work (we only point at tool-capable models) but stay
+ # conservative otherwise.
+ if provider in ("ollama", "lmstudio"):
return ModelCapabilities(
tools=True, vision=False, parallel_tool_calls=False, streaming=True
)
diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py
index 6c147705..e884624d 100644
--- a/coworker/providers/registry.py
+++ b/coworker/providers/registry.py
@@ -12,7 +12,7 @@
`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock`
(models in the user's own AWS account — Claude natively, everything else via Converse),
`vertex` (the user's own GCP project — Gemini and Claude natively, open-weight via the
-MaaS endpoint), and `ollama` (local, OpenAI-compatible `/v1`).
+MaaS endpoint), and the local servers `ollama` and `lmstudio` (both OpenAI-compatible `/v1`).
"""
from __future__ import annotations
@@ -30,6 +30,12 @@
from .vertex_provider import VertexProvider
DEFAULT_OLLAMA_URL = "http://localhost:11434"
+DEFAULT_LMSTUDIO_URL = "http://localhost:1234"
+
+# Local, keyless providers reached through their OpenAI-compatible `/v1` — grouped where
+# they need the same special-casing (the no-auth verify probe and its error copy).
+LOCAL_PROVIDERS = ("ollama", "lmstudio")
+_LOCAL_DEFAULT_URLS = {"ollama": DEFAULT_OLLAMA_URL, "lmstudio": DEFAULT_LMSTUDIO_URL}
@dataclass(frozen=True)
@@ -98,15 +104,15 @@ def to_dict(self) -> dict[str, Any]:
}
-def _normalize_ollama_url(url: Optional[str]) -> str:
- """Accept `http://host:11434` or `.../v1` and return an OpenAI-compatible base URL.
+def _normalize_local_url(url: Optional[str], default: str) -> str:
+ """Accept `http://host:port` or `.../v1` and return an OpenAI-compatible base URL.
- Ollama serves its OpenAI-compatible API under `/v1`; the native API lives at the root, so we
- always target `/v1`.
+ Ollama and LM Studio both serve their OpenAI-compatible API under `/v1` (their native
+ APIs live elsewhere on the same server), so we always target `/v1`.
"""
- base = (url or DEFAULT_OLLAMA_URL).strip().rstrip("/")
+ base = (url or default).strip().rstrip("/")
if not base:
- base = DEFAULT_OLLAMA_URL
+ base = default
if not base.endswith("/v1"):
base = base + "/v1"
return base
@@ -184,10 +190,17 @@ def get(key: str) -> Optional[str]:
def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Ollama's OpenAI-compatible endpoint ignores the key but the SDK requires a non-empty
# string, so we pass a placeholder. `base_url` comes from the stored profile (or the default).
- base_url = _normalize_ollama_url((profile or {}).get("base_url"))
+ base_url = _normalize_local_url((profile or {}).get("base_url"), DEFAULT_OLLAMA_URL)
return OpenAIProvider(api_key="ollama", base_url=base_url)
+def _build_lmstudio(profile: dict[str, Any], secrets: Any) -> ProviderClient:
+ # Same placeholder-key contract as Ollama: LM Studio's local server doesn't require a
+ # key out of the box, but the SDK insists on a non-empty string.
+ base_url = _normalize_local_url((profile or {}).get("base_url"), DEFAULT_LMSTUDIO_URL)
+ return OpenAIProvider(api_key="lm-studio", base_url=base_url)
+
+
def _openai_compat(vendor: str, default_base_url: str, env_key: Optional[str] = None):
"""Builder factory for vendors reached through their OpenAI-compatible API (Z AI, DeepSeek,
Kimi, MiniMax, Qwen, xAI, Mistral). The key is resolved from the vendor's OWN profile (or its
@@ -569,6 +582,26 @@ def _compat(
# `ollama pull qwen3-coder:30b`.
recommended_model="qwen3-coder:30b",
),
+ ProviderDescriptor(
+ name="lmstudio",
+ title="LM Studio (local models)",
+ needs_key=False,
+ fields=[
+ ProviderField(
+ "base_url",
+ "LM Studio server URL",
+ secret=False,
+ required=False,
+ placeholder=DEFAULT_LMSTUDIO_URL,
+ help="Where LM Studio's local server is listening (the Developer tab, or "
+ "`lms server start`). The OpenAI-compatible /v1 path is added automatically.",
+ ),
+ ],
+ build=_build_lmstudio,
+ # Ollama's verified tool-calling pick, under LM Studio's catalog id — download it
+ # in-app or with `lms get qwen/qwen3-coder-30b`.
+ recommended_model="qwen/qwen3-coder-30b",
+ ),
]
_BY_NAME = {d.name: d for d in DESCRIPTORS}
@@ -829,8 +862,8 @@ def verify_provider_key(
params={"key": key},
timeout=timeout,
)
- elif name == "ollama":
- base = _normalize_ollama_url(base_url)
+ elif name in LOCAL_PROVIDERS: # ollama / lmstudio: keyless local /v1
+ base = _normalize_local_url(base_url, _LOCAL_DEFAULT_URLS[name])
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
default_base = next(
@@ -853,12 +886,25 @@ def verify_provider_key(
}
if resp.status_code < 300:
+ # Local servers: a 2xx alone can't tell a model server from whatever else answers
+ # on that port (a dev server's HTML page, a proxy) — require the OpenAI list shape
+ # both serve at /v1/models. An empty model list still passes.
+ if name in LOCAL_PROVIDERS:
+ try:
+ shaped = isinstance(resp.json().get("data"), list)
+ except Exception:
+ shaped = False
+ if not shaped:
+ return {
+ "ok": False,
+ "error": "Reached the server, but no OpenAI-compatible /v1 API there.",
+ }
return {"ok": True}
if resp.status_code in (401, 403):
- if name == "ollama":
+ if name in LOCAL_PROVIDERS:
return {"ok": False, "error": "Server rejected the request."}
return {"ok": False, "error": "Invalid API key."}
- if resp.status_code == 404 and name == "ollama":
+ if resp.status_code == 404 and name in LOCAL_PROVIDERS:
return {
"ok": False,
"error": "Reached the server, but no OpenAI-compatible /v1 API there.",
diff --git a/coworker/server/manager.py b/coworker/server/manager.py
index ad76e996..cba5d147 100644
--- a/coworker/server/manager.py
+++ b/coworker/server/manager.py
@@ -80,6 +80,7 @@
provider_descriptors,
verify_provider_key,
)
+from ..providers.registry import DEFAULT_LMSTUDIO_URL, DEFAULT_OLLAMA_URL
from ..secrets import SecretStore, state_dir
from ..sessions import SessionRecord
from ..skills import (
@@ -1559,10 +1560,11 @@ def _note_provider_use(self, name: str) -> None:
def _suggested_models(self, name: str) -> list[str]:
"""Bare model-name suggestions for the 'add model' form (datalist), per provider.
- Ollama → live `/api/tags` (best-effort); everyone else → the curated matrix,
- topped up with the compat-vendor extras the matrix doesn't vouch for."""
- if name == "ollama":
- return [m.split(":", 1)[-1] for m in self._ollama_models()]
+ Local servers (Ollama, LM Studio) → their live model list (best-effort); everyone
+ else → the curated matrix, topped up with the compat-vendor extras the matrix
+ doesn't vouch for."""
+ if name in self.LOCAL_MODEL_SERVERS:
+ return [m.split(":", 1)[-1] for m in self._local_models(name)]
from ..providers.matrix import models_for_provider
return list(
@@ -1611,9 +1613,10 @@ def set_provider(
added = rec if name == "openai" else f"{name}:{rec}"
self.add_model(added)
# First working provider wins the default: if the current default model belongs to a
- # provider with no usable config (the fresh-install gpt-5.6-sol case), switch the default to
- # this provider's model. A default that already works is never stolen.
- if added and not self._provider_configured(self._model_provider(self.model)):
+ # provider with no usable config (the fresh-install gpt-5.6-sol case) or a local server
+ # that isn't answering, switch the default to this provider's model. A default that
+ # already works is never stolen.
+ if added and not self._provider_available(self._model_provider(self.model)):
self.set_default_model(added)
return {"ok": True, "provider": name, "recommended_model": rec}
@@ -1632,8 +1635,10 @@ def verify_provider(
self, name: str, fields: Optional[dict[str, Any]]
) -> dict[str, Any]:
"""Test a provider's credentials with a live read-only call, WITHOUT persisting them, so
- onboarding can offer a "Test" button. Falls back to stored/env values when the form left
- a field blank (e.g. testing an already-configured provider)."""
+ onboarding can offer a "Test" button. Blank secrets fall back to stored/env values (the
+ form masks a saved key); non-secret fields fall back only when OMITTED — one sent
+ explicitly blank means "back to the default", and resurrecting the stored value would
+ verify a URL that a subsequent save then removes."""
import os
d = get_descriptor(name)
@@ -1643,7 +1648,10 @@ def verify_provider(
profile = self.secrets.get(f"provider:{name}") or {}
merged = {}
for f in d.fields:
- val = fields.get(f.key) or profile.get(f.key) or ""
+ if f.secret or f.key not in fields:
+ val = fields.get(f.key) or profile.get(f.key) or ""
+ else:
+ val = fields.get(f.key) or ""
if isinstance(val, str):
val = val.strip()
if val:
@@ -1678,6 +1686,14 @@ def _provider_configured(self, name: str) -> bool:
return False
return descriptor_configured(d, self.secrets.get(f"provider:{name}") or {})
+ def _provider_available(self, name: str) -> bool:
+ """Whether a provider can serve RIGHT NOW. Local servers must actually answer
+ (their keyless "configured" proves nothing runs); everyone else must be
+ configured. Drives picker gating, `model_ready`, and default-model handoff."""
+ if name in self.LOCAL_MODEL_SERVERS:
+ return self._local_alive(name)
+ return self._provider_configured(name)
+
# -- settings / prefs (model API key, default model, onboarding) -------------
def _prefs_path(self) -> Path:
return self._data_base / "prefs.json"
@@ -1709,48 +1725,87 @@ def set_dm_session(self, session_id: Optional[str]) -> dict[str, Any]:
self._save_prefs()
return {"ok": True, "dm_session": self.dm_session()}
- def _ollama_alive(self) -> bool:
- """Best-effort local-Ollama liveness, cached 30s (get_settings runs on every GUI
- fetch — no 2s probe inline). Keyless is not the same as PRESENT: `ollama:*` picker
- entries render only when an Ollama actually answers, so a machine with no Ollama
- never shows phantom local models (e.g. a stray pasted string saved as a model id,
- caught 2026-07-21)."""
+ # Local model servers (Ollama, LM Studio): keyless, probed instead of key-checked.
+ # Ollama is listed via its native `/api/tags` ({"models": [{"name": …}]}); LM Studio
+ # via its OpenAI-compatible `/v1/models` ({"data": [{"id": …}]}) — its native REST
+ # API is beta and can sit behind an auth token.
+ LOCAL_MODEL_SERVERS: dict[str, dict[str, str]] = {
+ "ollama": {
+ "default_url": DEFAULT_OLLAMA_URL,
+ "models_path": "/api/tags",
+ "list_key": "models",
+ "id_key": "name",
+ },
+ "lmstudio": {
+ "default_url": DEFAULT_LMSTUDIO_URL,
+ "models_path": "/v1/models",
+ "list_key": "data",
+ "id_key": "id",
+ },
+ }
+
+ def _local_server_root(self, name: str) -> str:
+ """A local server's root URL (stored base_url or the default), with any `/v1`
+ suffix stripped so native and OpenAI-compat paths can both be appended."""
+ profile = self.secrets.get(f"provider:{name}") or {}
+ base = (
+ (profile.get("base_url") or self.LOCAL_MODEL_SERVERS[name]["default_url"])
+ .strip()
+ .rstrip("/")
+ )
+ if base.endswith("/v1"):
+ base = base[: -len("/v1")]
+ return base
+
+ def _local_models_url(self, name: str) -> str:
+ return self._local_server_root(name) + self.LOCAL_MODEL_SERVERS[name]["models_path"]
+
+ def _local_alive(self, name: str) -> bool:
+ """Best-effort local-server liveness, cached 30s per provider (get_settings runs on
+ every GUI fetch — no 2s probe inline). Keyless is not the same as PRESENT:
+ `ollama:*` / `lmstudio:*` picker entries render only when the server actually
+ answers, so a machine without one never shows phantom local models (e.g. a stray
+ pasted string saved as a model id, caught 2026-07-21)."""
import time
now = time.monotonic()
- cached = getattr(self, "_ollama_alive_cache", None)
+ cache = getattr(self, "_local_alive_cache", {})
+ cached = cache.get(name)
if cached and now - cached[0] < 30:
return cached[1]
- profile = self.secrets.get("provider:ollama") or {}
- base = (profile.get("base_url") or "http://localhost:11434").strip().rstrip("/")
- if base.endswith("/v1"):
- base = base[: -len("/v1")]
try:
import httpx
- alive = httpx.get(base + "/api/tags", timeout=0.8).status_code == 200
+ resp = httpx.get(self._local_models_url(name), timeout=0.8)
+ # Status alone can't tell a model server from whatever else answers on that
+ # port — require the expected list container (an empty list still counts).
+ alive = resp.status_code == 200 and isinstance(
+ resp.json().get(self.LOCAL_MODEL_SERVERS[name]["list_key"]), list
+ )
except Exception:
alive = False
- self._ollama_alive_cache = (now, alive)
+ cache[name] = (now, alive)
+ self._local_alive_cache = cache
return alive
- def _ollama_models(self) -> list[str]:
- """Live list of models pulled into the configured Ollama server (via its native
- `/api/tags`), as `ollama:` so they're directly selectable. Empty if Ollama isn't
- configured or unreachable — best-effort, never raises."""
- profile = self.secrets.get("provider:ollama")
- if not profile:
+ def _local_models(self, name: str) -> list[str]:
+ """Live list of the models available on a configured local server, as
+ `:` so they're directly selectable. Empty if the provider isn't
+ configured or unreachable — best-effort, never raises. Both servers list every
+ *downloaded* model; each loads one on demand when it's first requested."""
+ # `is None` (never engaged), not falsy: a keyless Detect stores an EMPTY profile,
+ # which must still probe the default endpoint — otherwise the recommended-model
+ # auto-add and the add-model suggestions never see a default-URL server.
+ if self.secrets.get(f"provider:{name}") is None:
return []
- base = (profile.get("base_url") or "http://localhost:11434").strip().rstrip("/")
- if base.endswith("/v1"):
- base = base[: -len("/v1")]
+ spec = self.LOCAL_MODEL_SERVERS[name]
try:
import httpx
- data = httpx.get(base + "/api/tags", timeout=2.0).json()
- return [
- f"ollama:{m['name']}" for m in data.get("models", []) if m.get("name")
- ]
+ data = httpx.get(self._local_models_url(name), timeout=2.0).json()
+ entries = data.get(spec["list_key"]) or []
+ ids = (m.get(spec["id_key"]) for m in entries)
+ return [f"{name}:{mid}" for mid in ids if mid]
except Exception:
return []
@@ -1816,13 +1871,10 @@ def get_settings(self) -> dict[str, Any]:
# Only surface models whose provider is actually configured — the composer picker
# reflects exactly what's connected. The active default is always kept selectable
# (it's hidden behind the "No model" state until a provider is connected anyway).
- # Ollama is keyless, so "configured" is meaningless there — its models show only
- # while a local Ollama answers (cached liveness probe).
+ # Local servers (Ollama, LM Studio) are keyless, so "configured" is meaningless —
+ # their models show only while the server answers (cached liveness probe).
def _selectable(m: str) -> bool:
- provider = self._model_provider(m)
- if provider == "ollama":
- return self._ollama_alive()
- return self._provider_configured(provider)
+ return self._provider_available(self._model_provider(m))
selectable = [m for m in self._curated_models() if _selectable(m)]
if self.model not in selectable:
@@ -1840,10 +1892,11 @@ def _selectable(m: str) -> bool:
# drives the composer's context-fill meter (absent id → meter hides).
"model_context_windows": model_context_windows(),
"has_key": env_key or stored,
- # Provider-agnostic "can this default model actually run?" — true when the default
- # model's provider is configured (any provider, not just OpenAI). Drives the GUI's
- # "No model connected" composer chip and the onboarding Skip warning.
- "model_ready": self._provider_configured(self._model_provider(self.model)),
+ # Provider-agnostic "can this default model actually run?" — the provider is
+ # configured, or for a local server, actually answering (a dead Ollama/LM Studio
+ # must not read as ready). Drives the GUI's "No model connected" composer chip
+ # and the onboarding Skip warning.
+ "model_ready": self._provider_available(self._model_provider(self.model)),
"source": "env" if env_key else ("store" if stored else None),
"onboarded": bool(self._prefs.get("onboarded")),
"experimental_connectors": experimental_enabled(self.secrets),
@@ -3755,6 +3808,14 @@ def _refresh_provider(self, name: Optional[str] = None) -> None:
invalidate = getattr(self.provider, "invalidate", None)
if callable(invalidate):
invalidate(name)
+ # A repointed or removed local server must re-probe immediately — otherwise the
+ # picker serves up-to-30s-stale liveness for the OLD endpoint.
+ cache = getattr(self, "_local_alive_cache", None)
+ if cache:
+ if name is None:
+ cache.clear()
+ else:
+ cache.pop(name, None)
# -- read models ------------------------------------------------------------
def list_sessions(self, workspace: Optional[str] = None) -> list[dict[str, Any]]:
diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts
index e6dfa409..dba26f4f 100644
--- a/surfaces/gui/e2e/fixtures.ts
+++ b/surfaces/gui/e2e/fixtures.ts
@@ -339,6 +339,8 @@ const PROVIDERS = [
// ollama: keyless local provider — "configured" without proving anything runs; the
// onboarding gallery shows "No key needed" and its form is endpoint + Detect (§39).
{ name: "ollama", title: "Ollama (local models)", needs_key: false, fields: [{ key: "base_url", label: "Endpoint", secret: false, required: false, help: "", placeholder: "http://127.0.0.1:11434", default: "http://127.0.0.1:11434" }], configured: true, values: {}, suggested_models: ["qwen3-coder:30b"], key_set_at: null, last_used_at: null },
+ // lmstudio: the second keyless local provider — same Detect flow, its own install copy.
+ { name: "lmstudio", title: "LM Studio (local models)", needs_key: false, fields: [{ key: "base_url", label: "LM Studio server URL", secret: false, required: false, help: "", placeholder: "http://localhost:1234", default: "" }], configured: true, values: {}, suggested_models: ["qwen/qwen3-coder-30b"], key_set_at: null, last_used_at: null },
];
/** Install the API + WebSocket mocks on a page. Returns handles for assertions/seed data. */
diff --git a/surfaces/gui/src/providers/ProviderSetup.test.tsx b/surfaces/gui/src/providers/ProviderSetup.test.tsx
index 870aa7a0..87e3a45f 100644
--- a/surfaces/gui/src/providers/ProviderSetup.test.tsx
+++ b/surfaces/gui/src/providers/ProviderSetup.test.tsx
@@ -1,11 +1,19 @@
// Auth-method segmented choice + show_when field visibility (Bedrock's "Connect with"):
// only the selected method's fields render, and clicking a segment switches them.
+// Plus the hook's keyless Detect contract (persist even when the form is clean).
import { afterEach, describe, expect, it, vi } from "vitest";
-import { cleanup, fireEvent, render, screen } from "@testing-library/react";
-import { ProviderForm, type ProviderSetupState } from "./ProviderSetup";
+import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { ProviderForm, useProviderSetup, type ProviderSetupState } from "./ProviderSetup";
+import * as api from "../api";
import type { ProviderInfo } from "../api";
vi.mock("../tauri", () => ({ openExternal: vi.fn() }));
+vi.mock("../api", () => ({
+ getProviders: vi.fn(),
+ setProvider: vi.fn(),
+ verifyProvider: vi.fn(),
+ removeProvider: vi.fn(),
+}));
afterEach(cleanup);
@@ -94,3 +102,56 @@ describe("ProviderForm auth-method choice", () => {
expect(screen.queryByTestId("t-field-bedrock_api_key")).toBeNull();
});
});
+
+const LMSTUDIO: ProviderInfo = {
+ name: "lmstudio",
+ title: "LM Studio (local models)",
+ needs_key: false,
+ configured: true, // keyless providers always report configured — even before first use
+ values: {},
+ suggested_models: [],
+ recommended_model: null,
+ fields: [
+ { key: "base_url", label: "LM Studio server URL", secret: false, required: false, help: "", placeholder: "http://localhost:1234" },
+ ],
+};
+
+function HookHarness({ grab }: { grab: (ps: ProviderSetupState) => void }) {
+ grab(useProviderSetup());
+ return null;
+}
+
+describe("useProviderSetup keyless Detect", () => {
+ it("persists a clean keyless form on a passing Detect", async () => {
+ // Keyless providers report `configured` out of the box, so a dirty/configured gate
+ // would skip the FIRST-time save — no stored profile, and the backend's
+ // recommended-model auto-add would never run (codex review, 2026-08-03).
+ vi.mocked(api.getProviders).mockResolvedValue([LMSTUDIO]);
+ vi.mocked(api.verifyProvider).mockResolvedValue({ ok: true });
+ vi.mocked(api.setProvider).mockResolvedValue({ ok: true });
+
+ let ps!: ProviderSetupState;
+ render( (ps = v)} />);
+ await waitFor(() => expect(ps.providers.length).toBe(1));
+ act(() => ps.openProvider("lmstudio"));
+ await act(async () => {
+ expect(await ps.runTestAndSave()).toBe(true);
+ });
+ expect(api.setProvider).toHaveBeenCalledWith("lmstudio", { base_url: "" });
+ });
+
+ it("a failed save surfaces as an error, not '✓ Tested & saved'", async () => {
+ vi.mocked(api.getProviders).mockResolvedValue([LMSTUDIO]);
+ vi.mocked(api.verifyProvider).mockResolvedValue({ ok: true });
+ vi.mocked(api.setProvider).mockResolvedValue({ ok: false, error: "disk full" });
+
+ let ps!: ProviderSetupState;
+ render( (ps = v)} />);
+ await waitFor(() => expect(ps.providers.length).toBe(1));
+ act(() => ps.openProvider("lmstudio"));
+ await act(async () => {
+ expect(await ps.runTestAndSave()).toBe(false);
+ });
+ expect(ps.verify).toEqual({ state: "error", msg: "disk full" });
+ });
+});
diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx
index 1d8dc881..581bd735 100644
--- a/surfaces/gui/src/providers/ProviderSetup.tsx
+++ b/surfaces/gui/src/providers/ProviderSetup.tsx
@@ -34,6 +34,13 @@ export const KEY_HELP: Record = {
xai: { url: "https://console.x.ai", label: "console.x.ai" },
};
+// Keyless local servers — the KEY_HELP counterpart: "install the app" instead of
+// "create a key".
+export const LOCAL_HELP: Record = {
+ ollama: { url: "https://ollama.com/download", name: "Ollama" },
+ lmstudio: { url: "https://lmstudio.ai/download", name: "LM Studio" },
+};
+
export type Verify = { state: "idle" | "testing" | "ok" | "error"; msg?: string };
/** Brand chip: always a light plate so multicolor marks read on any theme. */
@@ -102,8 +109,8 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
const [dirty, setDirty] = useState(false);
const [showEndpoint, setShowEndpoint] = useState(false);
const [verify, setVerify] = useState({ state: "idle" });
- // Keyless providers (Ollama) report configured without proving anything runs —
- // a passing Detect this session is what marks them live.
+ // Keyless providers (Ollama, LM Studio) report configured without proving anything
+ // runs — a passing Detect this session is what marks them live.
const [keylessOk, setKeylessOk] = useState>(new Set());
// Unsaved per-provider input survives switching cards (owner complaint 2026-07-16).
const [drafts, setDrafts] = useState>>({});
@@ -159,7 +166,20 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
setVerify({ state: "error", msg: res.error || "couldn't verify" });
return false;
}
- if (dirty || !info?.configured) await setProvider(sel, fields).catch(() => {});
+ // Keyless providers save on every passing Detect: they report `configured` out of
+ // the box, so the dirty/configured gate would skip the FIRST-time save — leaving no
+ // stored profile, which means set_provider's recommended-model auto-add never runs.
+ // A failed save must surface, not masquerade as "✓ Tested & saved".
+ if (dirty || !info?.configured || !info?.needs_key) {
+ const saved = await setProvider(sel, fields).catch(() => ({ ok: false as const }));
+ if (!saved?.ok) {
+ setVerify({
+ state: "error",
+ msg: ("error" in saved && saved.error) || "verified, but saving failed — try again",
+ });
+ return false;
+ }
+ }
if (!info?.needs_key) setKeylessOk((s) => new Set(s).add(sel));
setVerify({ state: "ok" });
setDirty(false);
@@ -337,7 +357,7 @@ export function ProviderForm({
)
: [];
// Without a choice control, Test lives next to the required secret (the API key), or
- // the first field for keyless providers (Ollama's Detect).
+ // the first field for keyless providers (Ollama's / LM Studio's Detect).
const requiredSecret = fieldsAll.find((x) => x.secret && x.required);
const testKey = requiredSecret ? requiredSecret.key : fieldsAll[0]?.key;
if (!sel) return null;
@@ -497,14 +517,14 @@ export function ProviderForm({
— takes about a minute.