From dad1b61383dba24c9ea9e8b23bd3e8b03dd73a88 Mon Sep 17 00:00:00 2001
From: zalex
Date: Mon, 3 Aug 2026 12:52:24 -0700
Subject: [PATCH 1/3] Add LM Studio as a local model provider
Second keyless local provider alongside Ollama, riding the same
OpenAI-compatible /v1 path (OpenAIProvider with a placeholder key,
default http://localhost:1234):
- registry: lmstudio descriptor + builder; _normalize_ollama_url
generalized to _normalize_local_url(url, default); the keyless
verify branch (GET /v1/models, no auth) now covers both local
providers
- capabilities: lmstudio gets the same conservative local-model
defaults as ollama (tools yes, parallel/vision no)
- manager: _ollama_alive/_ollama_models generalized to
_local_alive/_local_models driven by a LOCAL_MODEL_SERVERS table
(ollama via native /api/tags, LM Studio via OpenAI-shaped
/v1/models); picker gating and suggestions follow
- GUI: lobe-icons LM Studio mark, gallery order, and the keyless
install-help copy generalized into a LOCAL_HELP map
- tests mirror the existing Ollama coverage (normalize/build/verify/
capabilities/liveness gating) plus a parse test for both list shapes
Co-Authored-By: Claude Fable 5
---
README.md | 6 +-
coworker/providers/capabilities.py | 7 +-
coworker/providers/registry.py | 57 +++++++++---
coworker/server/manager.py | 91 ++++++++++++-------
surfaces/gui/src/providers/ProviderSetup.tsx | 21 +++--
surfaces/gui/src/providers/logos.ts | 3 +
surfaces/gui/src/providers/logos/lmstudio.svg | 1 +
tests/test_provider_router.py | 43 +++++++--
tests/test_provider_verify.py | 8 ++
tests/test_settings.py | 57 +++++++++++-
10 files changed, 225 insertions(+), 69 deletions(-)
create mode 100644 surfaces/gui/src/providers/logos/lmstudio.svg
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..aa44c51f 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(
@@ -855,10 +888,10 @@ def verify_provider_key(
if resp.status_code < 300:
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..50b0f2ae 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(
@@ -1709,48 +1711,69 @@ 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`; LM Studio via its OpenAI-compatible
+ # `/v1/models` (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"},
+ "lmstudio": {"default_url": DEFAULT_LMSTUDIO_URL, "models_path": "/v1/models"},
+ }
+
+ 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
+ alive = httpx.get(self._local_models_url(name), timeout=0.8).status_code == 200
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."""
+ if not self.secrets.get(f"provider:{name}"):
return []
- base = (profile.get("base_url") or "http://localhost:11434").strip().rstrip("/")
- if base.endswith("/v1"):
- base = base[: -len("/v1")]
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()
+ # Ollama's /api/tags: {"models": [{"name": …}]}; LM Studio's /v1/models
+ # (OpenAI list shape): {"data": [{"id": …}]}.
+ entries = data.get("models") or data.get("data") or []
+ ids = (m.get("name") or m.get("id") for m in entries)
+ return [f"{name}:{mid}" for mid in ids if mid]
except Exception:
return []
@@ -1816,12 +1839,12 @@ 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()
+ if provider in self.LOCAL_MODEL_SERVERS:
+ return self._local_alive(provider)
return self._provider_configured(provider)
selectable = [m for m in self._curated_models() if _selectable(m)]
diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx
index 1d8dc881..b99ab493 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>>({});
@@ -337,7 +344,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 +504,14 @@ export function ProviderForm({
— takes about a minute.