From ae87ed49aab6949b5366e54081e7e66f3e344e65 Mon Sep 17 00:00:00 2001
From: Buddhika Godakanda <54194807+hacksics@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:20:25 +0530
Subject: [PATCH 1/3] Add LM Studio as a local model provider
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Local inference was Ollama-only. LM Studio worked solely by pointing the
OpenAI provider at localhost — which burns the single provider:openai
profile (so local and hosted OpenAI become mutually exclusive), demands a
key it ignores, and gets no liveness gating, leaving dead entries in the
picker whenever the server is closed.
Model discovery prefers LM Studio's typed /api/v0/models over the stable
/v1/models: the latter lists embedding models alongside chat models with
nothing in the id to tell them apart. That route is beta, so any failure
falls back to /v1 unfiltered.
Connecting a local runtime now selects a model it actually has. A single
named recommendation nearly always misses when the catalogue is whatever
the user happened to download, and the old behaviour left a working local
server still reading as "No model".
The key is passed explicitly rather than via `secrets`, so a real
OPENAI_API_KEY in the environment can never reach a local endpoint.
Verified against LM Studio 0.3.x: discovery, embedding filtering, verify,
and a live tool-calling completion through the router.
Co-Authored-By: Claude
---
coworker/providers/__init__.py | 2 +
coworker/providers/capabilities.py | 7 +-
coworker/providers/registry.py | 92 ++++++++++++--
coworker/server/manager.py | 116 ++++++++++++++----
surfaces/gui/src/providers/ProviderSetup.tsx | 24 +++-
surfaces/gui/src/providers/logos.ts | 3 +
tests/test_provider_router.py | 57 ++++++++-
tests/test_provider_verify.py | 19 +++
tests/test_settings.py | 121 +++++++++++++++++++
9 files changed, 399 insertions(+), 42 deletions(-)
diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py
index 6b34c141..da0162fc 100644
--- a/coworker/providers/__init__.py
+++ b/coworker/providers/__init__.py
@@ -10,6 +10,7 @@
from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider, resolve_api_key
from .registry import (
+ LOCAL_PROVIDERS,
ProviderDescriptor,
ProviderField,
build_provider_client,
@@ -41,4 +42,5 @@
"build_provider_client",
"detect_provider",
"verify_provider_key",
+ "LOCAL_PROVIDERS",
]
diff --git a/coworker/providers/capabilities.py b/coworker/providers/capabilities.py
index 94755c53..a31e622d 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":
+ # Locally-run models (Ollama, LM Studio) vary widely and many fake/mishandle parallel tool
+ # calls — assume tools work (we only point at tool-capable models) but stay conservative
+ # otherwise. Whoever serves them, the weights are the same, so the answer is too.
+ 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 b5c628c6..b7781b34 100644
--- a/coworker/providers/registry.py
+++ b/coworker/providers/registry.py
@@ -8,8 +8,14 @@
Today: `openai` (the default, with an optional custom endpoint that covers Azure OpenAI's
`/openai/v1` and any OpenAI-compliant gateway), `anthropic` (native Messages API via
-`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), and `ollama`
-(local, OpenAI-compatible `/v1`). Bedrock/Vertex auth for Claude is future work.
+`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), and two local
+runtimes — `ollama` and `lmstudio` — both reached over their OpenAI-compatible `/v1`.
+Bedrock/Vertex auth for Claude is future work.
+
+The local pair are separate providers rather than one "point OpenAI at a custom endpoint"
+setting: a single `provider:openai` profile can only hold one endpoint, so folding a local
+server into it would make local and hosted OpenAI mutually exclusive. They also need to be
+keyless (both ignore the key) and liveness-gated by the manager, which the keyed path is not.
"""
from __future__ import annotations
@@ -24,6 +30,7 @@
from .openai_provider import OpenAIProvider
DEFAULT_OLLAMA_URL = "http://localhost:11434"
+DEFAULT_LMSTUDIO_URL = "http://localhost:1234"
@dataclass(frozen=True)
@@ -81,20 +88,43 @@ 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 _openai_v1_base(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`.
+ Both local runtimes serve their OpenAI-compatible API under `/v1` while their native API
+ lives at the root, so we always target `/v1`. Users paste either form.
"""
- 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
+def _normalize_ollama_url(url: Optional[str]) -> str:
+ return _openai_v1_base(url, DEFAULT_OLLAMA_URL)
+
+
+def _normalize_lmstudio_url(url: Optional[str]) -> str:
+ return _openai_v1_base(url, DEFAULT_LMSTUDIO_URL)
+
+
+# Keyless runtimes on the user's own machine. They share three behaviours the keyed providers
+# don't have: no credential to validate, a default localhost endpoint, and manager-side
+# liveness gating so their models leave the picker when the server is closed.
+LOCAL_PROVIDERS = ("ollama", "lmstudio")
+LOCAL_DEFAULT_URLS = {
+ "ollama": DEFAULT_OLLAMA_URL,
+ "lmstudio": DEFAULT_LMSTUDIO_URL,
+}
+
+
+def _local_v1_base(name: str, url: Optional[str]) -> str:
+ """OpenAI-compatible `/v1` base for a local provider, defaulting to its usual port."""
+ return _openai_v1_base(url, LOCAL_DEFAULT_URLS.get(name, DEFAULT_OLLAMA_URL))
+
+
def _build_openai(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Key resolution stays in OpenAIProvider/resolve_api_key (explicit → env → SecretStore),
# so we just hand it the SecretStore. An optional custom endpoint (Azure OpenAI /openai/v1,
@@ -133,6 +163,15 @@ def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient:
return OpenAIProvider(api_key="ollama", base_url=base_url)
+def _build_lmstudio(profile: dict[str, Any], secrets: Any) -> ProviderClient:
+ # Same shape as Ollama: LM Studio's local server ignores the key, but the SDK insists on a
+ # non-empty string. Note we pass `api_key` EXPLICITLY rather than handing over `secrets` —
+ # that keeps OpenAIProvider's env fallback out of the picture, so a real OPENAI_API_KEY in
+ # the environment is never sent to a local server (the same rule the compat vendors follow).
+ base_url = _normalize_lmstudio_url((profile or {}).get("base_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
@@ -352,6 +391,27 @@ 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 (Developer tab ▸ Start Server). The OpenAI-compatible /v1 path is added automatically.",
+ ),
+ ],
+ build=_build_lmstudio,
+ # Only auto-added if it is actually loaded (set_provider checks the live model list),
+ # so naming one costs nothing on a machine running something else.
+ recommended_model="qwen/qwen3-coder-30b",
+ # No blurb: the GUI already renders a "no API key needed / install it" note for every
+ # keyless provider, and a second line saying the same thing reads as clutter.
+ ),
]
_BY_NAME = {d.name: d for d in DESCRIPTORS}
@@ -408,6 +468,8 @@ def verify_provider_key(
d = _BY_NAME.get(name) or _BY_NAME["openai"]
key = (api_key or "").strip()
+ # Resolved up here so the connection-error message below can name the endpoint we tried.
+ base = _local_v1_base(name, base_url) if name in LOCAL_PROVIDERS else ""
try:
if name == "anthropic":
resp = httpx.get(
@@ -421,8 +483,10 @@ def verify_provider_key(
params={"key": key},
timeout=timeout,
)
- elif name == "ollama":
- base = _normalize_ollama_url(base_url)
+ elif name in LOCAL_PROVIDERS:
+ # Keyless, and always explicit about the endpoint: falling through to the generic
+ # branch below would default an empty base_url to api.openai.com and send it an
+ # empty bearer token.
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
default_base = next(
@@ -439,6 +503,10 @@ def verify_provider_key(
timeout=timeout,
)
except Exception as exc: # DNS/connection/timeout — never let it bubble to a 500
+ if name in LOCAL_PROVIDERS:
+ # By far the most common cause locally is simply that the server isn't started,
+ # which "Couldn't reach (ConnectError)" doesn't tell anyone what to do about.
+ return {"ok": False, "error": f"Nothing listening at {base}. Is it running?"}
return {
"ok": False,
"error": f"Couldn't reach {d.title} ({exc.__class__.__name__}).",
@@ -447,10 +515,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 b2d9be0c..140eae6f 100644
--- a/coworker/server/manager.py
+++ b/coworker/server/manager.py
@@ -73,6 +73,7 @@
from ..permissions import Mode
from ..agents import list_agents as _list_agents
from ..providers import (
+ LOCAL_PROVIDERS,
ProviderClient,
ProviderRouter,
get_descriptor,
@@ -1493,10 +1494,16 @@ 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."""
+ Local runtimes → whatever that server actually has (best-effort live query); everyone
+ else → the curated matrix, topped up with the compat-vendor extras the matrix doesn't
+ vouch for. The matrix deliberately carries no local entries: which models exist is a
+ property of the user's machine, not something we can curate."""
if name == "ollama":
return [m.split(":", 1)[-1] for m in self._ollama_models()]
+ if name == "lmstudio":
+ # `split(":", 1)` — LM Studio ids are namespaced with a slash ("qwen/qwen3-…"),
+ # so only the provider prefix is ever stripped.
+ return [m.split(":", 1)[-1] for m in self._lmstudio_models()]
from ..providers.matrix import models_for_provider
return list(
@@ -1540,9 +1547,18 @@ def set_provider(
# the curated list so it shows up in the composer right after configuring the provider.
rec = d.recommended_model
added: Optional[str] = None
- if rec and rec in self._suggested_models(name):
+ available = self._suggested_models(name)
+ if rec and rec in available:
# OpenAI models stay bare (the router's default); others carry their prefix.
added = rec if name == "openai" else f"{name}:{rec}"
+ elif name in LOCAL_PROVIDERS and available:
+ # Local runtimes serve whatever the user happens to have downloaded, so a single
+ # named recommendation usually misses. Falling back to something they actually
+ # have is what makes "connect it and it works" true — otherwise connecting a
+ # perfectly good local server still leaves the composer reading "No model".
+ rec = available[0]
+ added = f"{name}:{rec}"
+ if added:
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
@@ -1633,31 +1649,52 @@ 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)."""
+ def _local_root(self, name: str) -> str:
+ """Configured (or default) ROOT url for a local runtime, with any `/v1` trimmed — the
+ native APIs we probe below hang off the root, not the OpenAI-compatible path."""
+ from ..providers.registry import LOCAL_DEFAULT_URLS
+
+ default = LOCAL_DEFAULT_URLS[name]
+ profile = self.secrets.get(f"provider:{name}") or {}
+ base = (profile.get("base_url") or default).strip().rstrip("/")
+ if base.endswith("/v1"):
+ base = base[: -len("/v1")]
+ return base or default
+
+ def _local_alive(self, name: str, path: str) -> bool:
+ """Best-effort liveness for a local runtime, cached 30s per provider (get_settings runs
+ on every GUI fetch — no 2s probe inline). Keyless is not the same as PRESENT: a local
+ provider's picker entries render only while its server actually answers, so a machine
+ with no Ollama / no LM Studio 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", None)
+ if cache is None:
+ cache = {}
+ self._local_alive_cache = 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_root(name) + path, timeout=0.8).status_code == 200
+ )
except Exception:
alive = False
- self._ollama_alive_cache = (now, alive)
+ cache[name] = (now, alive)
return alive
+ def _ollama_alive(self) -> bool:
+ return self._local_alive("ollama", "/api/tags")
+
+ def _lmstudio_alive(self) -> bool:
+ # LM Studio has no root health route; its OpenAI-compatible model list is the probe.
+ return self._local_alive("lmstudio", "/v1/models")
+
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
@@ -1665,19 +1702,52 @@ def _ollama_models(self) -> list[str]:
profile = self.secrets.get("provider:ollama")
if not profile:
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()
+ data = httpx.get(self._local_root("ollama") + "/api/tags", timeout=2.0).json()
return [
f"ollama:{m['name']}" for m in data.get("models", []) if m.get("name")
]
except Exception:
return []
+ def _lmstudio_models(self) -> list[str]:
+ """Live list of models LM Studio can serve, as `lmstudio:`. Empty if LM Studio
+ isn't configured or its server isn't started — best-effort, never raises.
+
+ Prefers LM Studio's own `/api/v0/models` because it types each entry, letting us drop
+ embedding models — `/v1/models` returns them mixed in with chat models and nothing in
+ an id reliably distinguishes the two. That route is beta, so any failure (older build,
+ renamed path) falls back to the stable OpenAI-compatible list unfiltered.
+ """
+ profile = self.secrets.get("provider:lmstudio")
+ if not profile:
+ return []
+ root = self._local_root("lmstudio")
+ try:
+ import httpx
+
+ data = httpx.get(root + "/api/v0/models", timeout=2.0).json()
+ models = [m for m in data.get("data", []) if m.get("id")]
+ if models:
+ return [
+ f"lmstudio:{m['id']}"
+ for m in models
+ # Keep anything not explicitly an embedder: unknown/absent types are
+ # likelier to be a new chat variant than a new kind of embedding model.
+ if m.get("type") not in ("embeddings", "embedding")
+ ]
+ except Exception:
+ pass
+ try:
+ import httpx
+
+ data = httpx.get(root + "/v1/models", timeout=2.0).json()
+ return [f"lmstudio:{m['id']}" for m in data.get("data", []) if m.get("id")]
+ except Exception:
+ return []
+
def _curated_models(self) -> list[str]:
"""The models offered in the composer's selector: every curated-matrix model
(`get_settings` culls the ones whose provider has no key) plus custom ids the user
@@ -1740,12 +1810,14 @@ 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 runtimes are keyless, so "configured" is meaningless there — their models show
+ # only while that server answers (cached liveness probe).
def _selectable(m: str) -> bool:
provider = self._model_provider(m)
if provider == "ollama":
return self._ollama_alive()
+ if provider == "lmstudio":
+ return self._lmstudio_alive()
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 eef8c8b7..83fd080b 100644
--- a/surfaces/gui/src/providers/ProviderSetup.tsx
+++ b/surfaces/gui/src/providers/ProviderSetup.tsx
@@ -31,6 +31,22 @@ export const KEY_HELP: Record = {
xai: { url: "https://console.x.ai", label: "console.x.ai" },
};
+// Keyless local runtimes: there's no key to fetch, so the equivalent help is "where do I get
+// the app, and what do I have to do in it". LM Studio needs the extra nudge — unlike `ollama
+// serve`, its server is off by default and lives behind a tab most users never open.
+export const LOCAL_HELP: Record = {
+ ollama: {
+ url: "https://ollama.com/download",
+ label: "Install Ollama",
+ note: "No API key needed — Ollama runs models on this Mac.",
+ },
+ lmstudio: {
+ url: "https://lmstudio.ai/download",
+ label: "Install LM Studio",
+ note: "No API key needed — LM Studio runs models on this Mac. Start its server first: Developer tab ▸ Start Server.",
+ },
+};
+
export type Verify = { state: "idle" | "testing" | "ok" | "error"; msg?: string };
/** Brand chip: always a light plate so multicolor marks read on any theme. */
@@ -406,14 +422,14 @@ export function ProviderForm({
— takes about a minute.
- No API key needed — Ollama runs models on this Mac.{" "}
+ {LOCAL_HELP[sel].note}{" "}
)}
diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts
index 14c10332..0b385bdd 100644
--- a/surfaces/gui/src/providers/logos.ts
+++ b/surfaces/gui/src/providers/logos.ts
@@ -42,7 +42,10 @@ export const PROVIDER_ORDER = [
"openai",
"gemini",
"meta",
+ // The two local runtimes sit together, right after the hosted names people recognize.
+ // lmstudio has no vendored mark yet, so ProviderMark falls back to its "L" monogram.
"ollama",
+ "lmstudio",
"fireworks",
"together",
"zai",
diff --git a/tests/test_provider_router.py b/tests/test_provider_router.py
index 81e53d30..b02027ef 100644
--- a/tests/test_provider_router.py
+++ b/tests/test_provider_router.py
@@ -14,7 +14,11 @@
StreamChunk,
capabilities_for,
)
-from coworker.providers.registry import _normalize_ollama_url, build_provider_client
+from coworker.providers.registry import (
+ _normalize_lmstudio_url,
+ _normalize_ollama_url,
+ build_provider_client,
+)
from coworker.providers.openai_provider import _salvage_tool_calls_from_text
@@ -55,6 +59,40 @@ def test_normalize_ollama_url():
assert _normalize_ollama_url(" ") == "http://localhost:11434/v1"
+def test_normalize_lmstudio_url():
+ assert _normalize_lmstudio_url(None) == "http://localhost:1234/v1"
+ assert _normalize_lmstudio_url("http://localhost:1234") == "http://localhost:1234/v1"
+ assert _normalize_lmstudio_url("http://box:1234/v1/") == "http://box:1234/v1"
+ assert _normalize_lmstudio_url(" ") == "http://localhost:1234/v1"
+
+
+def test_build_lmstudio_client_never_uses_the_openai_key(monkeypatch):
+ """A local server must never receive a real hosted key. `_build_lmstudio` passes a
+ placeholder explicitly rather than handing over `secrets`, so OpenAIProvider's
+ env/SecretStore fallback can't reach it."""
+ captured: dict = {}
+
+ class FakeOpenAI:
+ def __init__(self, **kwargs):
+ captured.update(kwargs)
+
+ monkeypatch.setattr("openai.OpenAI", FakeOpenAI)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-real-hosted-key")
+
+ class _Secrets: # would hand over a stored key if anyone asked it
+ def get(self, _k):
+ return {"api_key": "sk-stored-hosted-key"}
+
+ client = build_provider_client(
+ "lmstudio", {"base_url": "http://box:1234"}, secrets=_Secrets()
+ )
+ client._ensure_client() # type: ignore[attr-defined]
+ assert captured["base_url"] == "http://box:1234/v1"
+ assert captured["api_key"] == "lm-studio" # placeholder; LM Studio ignores it
+ assert "sk-real-hosted-key" not in captured.values()
+ assert "sk-stored-hosted-key" not in captured.values()
+
+
def test_build_ollama_client_uses_base_url(monkeypatch):
captured: dict = {}
@@ -148,6 +186,15 @@ def test_router_capabilities_prefix_aware():
assert router.capabilities("ollama:qwen2.5-coder").parallel_tool_calls is False
+def test_router_strips_lmstudio_prefix_from_namespaced_ids():
+ # LM Studio ids are "publisher/model" — the slash must survive, only the provider goes.
+ r = ProviderRouter(secrets=None)
+ assert r._bare("lmstudio:qwen/qwen3.5-9b") == "qwen/qwen3.5-9b"
+ assert r._provider_name("lmstudio:qwen/qwen3.5-9b") == "lmstudio"
+ # and an unprefixed namespaced id is NOT mistaken for a provider
+ assert r._bare("qwen/qwen3.5-9b") == "qwen/qwen3.5-9b"
+
+
# -- capabilities ---------------------------------------------------------------
def test_capabilities_ollama():
caps = capabilities_for("ollama:qwen2.5-coder")
@@ -156,6 +203,14 @@ def test_capabilities_ollama():
assert caps.vision is False
+def test_capabilities_lmstudio_matches_ollama():
+ # Same weights, same answer — whoever is serving them locally.
+ caps = capabilities_for("lmstudio:qwen/qwen3.5-9b")
+ assert caps.tools is True
+ assert caps.parallel_tool_calls is False
+ assert caps.vision is False
+
+
# -- tool-call salvage (Ollama emits tool calls as text) ------------------------
def test_salvage_bare_json_object():
calls = _salvage_tool_calls_from_text(
diff --git a/tests/test_provider_verify.py b/tests/test_provider_verify.py
index c24414e8..bcbe75fb 100644
--- a/tests/test_provider_verify.py
+++ b/tests/test_provider_verify.py
@@ -90,6 +90,25 @@ def test_verify_ollama_uses_v1_models_no_key(monkeypatch):
assert "headers" not in cap # keyless
+def test_verify_lmstudio_defaults_to_its_own_port(monkeypatch):
+ cap: dict = {}
+ _patch_get(monkeypatch, status=200, capture=cap)
+ # Blank endpoint must resolve to LM Studio's default — NOT fall through to the generic
+ # branch, which would probe api.openai.com with an empty bearer token.
+ assert verify_provider_key("lmstudio", base_url="") == {"ok": True}
+ assert cap["url"] == "http://localhost:1234/v1/models"
+ assert "headers" not in cap # keyless
+
+
+def test_verify_local_connection_error_names_the_endpoint(monkeypatch):
+ # "Couldn't reach (ConnectError)" doesn't tell a local user what to do; the overwhelmingly
+ # common cause is that they haven't started the server.
+ _patch_get(monkeypatch, raise_exc=ConnectionError("boom"))
+ res = verify_provider_key("lmstudio", base_url="http://localhost:4321")
+ assert res["ok"] is False
+ assert res["error"] == "Nothing listening at http://localhost:4321/v1. Is it running?"
+
+
def test_verify_network_error_is_clean(monkeypatch):
_patch_get(monkeypatch, raise_exc=ConnectionError("boom"))
res = verify_provider_key("openai", api_key="sk-x")
diff --git a/tests/test_settings.py b/tests/test_settings.py
index 873940e2..da674d61 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -174,3 +174,124 @@ def test_ollama_models_gated_on_liveness(tmp_path, monkeypatch):
monkeypatch.setattr(SessionManager, "_ollama_alive", lambda self: True)
assert "ollama:llama3.3" in manager.get_settings()["models"]
+
+
+def test_lmstudio_models_gated_on_liveness(tmp_path, monkeypatch):
+ """Same rule as Ollama: keyless must not mean always-present. LM Studio's server is OFF by
+ default, so an unstarted one would otherwise leave dead entries in the picker."""
+ from coworker.server.manager import SessionManager
+
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+ monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
+ manager = SessionManager(data_dir=tmp_path / "data")
+ manager.add_model("lmstudio:qwen/qwen3.5-9b")
+
+ monkeypatch.setattr(SessionManager, "_lmstudio_alive", lambda self: False)
+ assert "lmstudio:qwen/qwen3.5-9b" not in manager.get_settings()["models"]
+
+ monkeypatch.setattr(SessionManager, "_lmstudio_alive", lambda self: True)
+ assert "lmstudio:qwen/qwen3.5-9b" in manager.get_settings()["models"]
+
+
+def _fake_json_get(monkeypatch, routes: dict):
+ """Route httpx.get by URL suffix; anything unrouted raises (as an unreachable host would)."""
+ from types import SimpleNamespace
+
+ def fake_get(url, **_kw):
+ for suffix, payload in routes.items():
+ if url.endswith(suffix):
+ return SimpleNamespace(status_code=200, json=lambda p=payload: p)
+ raise ConnectionError(url)
+
+ monkeypatch.setattr("httpx.get", fake_get)
+
+
+def test_lmstudio_discovery_drops_embedding_models(tmp_path, monkeypatch):
+ """`/v1/models` lists embedders alongside chat models with nothing in the id to tell them
+ apart, so discovery prefers LM Studio's typed `/api/v0/models`. Shapes captured from a live
+ LM Studio 0.3.x on 2026-07-26."""
+ monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
+ from coworker.server.manager import SessionManager
+
+ manager = SessionManager(data_dir=tmp_path / "data")
+ manager.secrets.put("provider:lmstudio", {"base_url": "http://localhost:1234"})
+ _fake_json_get(
+ monkeypatch,
+ {
+ "/api/v0/models": {
+ "data": [
+ {"id": "qwen/qwen3.5-9b", "type": "vlm"},
+ {"id": "google/gemma-3-4b", "type": "vlm"},
+ {"id": "text-embedding-nomic-embed-text-v1.5", "type": "embeddings"},
+ ]
+ }
+ },
+ )
+ assert manager._lmstudio_models() == [
+ "lmstudio:qwen/qwen3.5-9b",
+ "lmstudio:google/gemma-3-4b",
+ ]
+ # bare ids for the datalist — the namespacing slash survives, only the prefix goes
+ assert manager._suggested_models("lmstudio") == [
+ "qwen/qwen3.5-9b",
+ "google/gemma-3-4b",
+ ]
+
+
+def test_lmstudio_discovery_falls_back_to_v1(tmp_path, monkeypatch):
+ """`/api/v0` is beta. An older build (or a renamed path) must degrade to the stable
+ OpenAI-compatible list rather than reporting no models at all."""
+ monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
+ from coworker.server.manager import SessionManager
+
+ manager = SessionManager(data_dir=tmp_path / "data")
+ manager.secrets.put("provider:lmstudio", {"base_url": "http://localhost:1234"})
+ _fake_json_get( # only /v1/models is routed; /api/v0/models raises
+ monkeypatch, {"/v1/models": {"data": [{"id": "qwen/qwen3.5-9b"}]}}
+ )
+ assert manager._lmstudio_models() == ["lmstudio:qwen/qwen3.5-9b"]
+
+
+def test_lmstudio_discovery_silent_when_unconfigured(tmp_path, monkeypatch):
+ """No profile → no probe at all. get_providers() runs on every Settings fetch; a blocking
+ HTTP call per unconfigured local provider would tax every user who has neither."""
+ monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
+ from coworker.server.manager import SessionManager
+
+ manager = SessionManager(data_dir=tmp_path / "data")
+
+ def boom(*_a, **_k):
+ raise AssertionError("probed an unconfigured local provider")
+
+ monkeypatch.setattr("httpx.get", boom)
+ assert manager._lmstudio_models() == []
+ assert manager._ollama_models() == []
+
+
+def test_connecting_a_local_runtime_selects_a_model_it_actually_has(
+ tmp_path, monkeypatch
+):
+ """Local runtimes serve whatever the user downloaded, so the named recommendation usually
+ misses. Connecting one must still leave a usable default — otherwise a perfectly good
+ local server still reads as "No model"."""
+ monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
+ for var in ("OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GEMINI_API_KEY"):
+ monkeypatch.delenv(var, raising=False)
+ from coworker.server.manager import SessionManager
+
+ monkeypatch.setattr(SessionManager, "_lmstudio_alive", lambda self: True)
+ manager = SessionManager(data_dir=tmp_path / "data")
+ assert manager.model == "gpt-5.6-sol" # fresh install, nothing configured
+
+ # The descriptor recommends qwen3-coder-30b; this machine has neither of those.
+ monkeypatch.setattr(
+ manager,
+ "_suggested_models",
+ lambda name: ["qwen/qwen3.5-9b", "google/gemma-3-4b"]
+ if name == "lmstudio"
+ else [],
+ )
+ res = manager.set_provider("lmstudio", {"base_url": "http://localhost:1234"})
+ assert res["ok"] and res["recommended_model"] == "qwen/qwen3.5-9b"
+ assert manager.model == "lmstudio:qwen/qwen3.5-9b"
+ assert "lmstudio:qwen/qwen3.5-9b" in manager.get_settings()["models"]
From 447e3d4e9bbfe17c18749e18b22a0d531d15caf9 Mon Sep 17 00:00:00 2001
From: Buddhika Godakanda <54194807+hacksics@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:31:38 +0530
Subject: [PATCH 2/3] Persist the connection when a local runtime uses its
default endpoint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Connecting Ollama or LM Studio without editing the endpoint stored no
profile at all, and model discovery reads that profile to decide the
runtime was opted into — so the card went green while the picker stayed
empty.
Two halves. The GUI's Test-is-also-Save path skipped the save whenever a
provider already reported `configured`, which keyless providers do from
the first render (there is no key to be missing). And discovery treated a
stored-but-empty profile as absent; connecting on the default endpoint has
nothing to write, so `{}` is the correct representation of "connected" and
`is None` is the right test.
Pre-existing for Ollama; found while adding LM Studio.
Co-Authored-By: Claude
---
coworker/providers/__init__.py | 2 ++
coworker/server/manager.py | 12 ++++----
surfaces/gui/src/providers/ProviderSetup.tsx | 11 +++++--
tests/test_settings.py | 31 ++++++++++++++++++++
4 files changed, 48 insertions(+), 8 deletions(-)
diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py
index da0162fc..6fca760e 100644
--- a/coworker/providers/__init__.py
+++ b/coworker/providers/__init__.py
@@ -10,6 +10,7 @@
from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider, resolve_api_key
from .registry import (
+ LOCAL_DEFAULT_URLS,
LOCAL_PROVIDERS,
ProviderDescriptor,
ProviderField,
@@ -43,4 +44,5 @@
"detect_provider",
"verify_provider_key",
"LOCAL_PROVIDERS",
+ "LOCAL_DEFAULT_URLS",
]
diff --git a/coworker/server/manager.py b/coworker/server/manager.py
index 140eae6f..48b2d1d2 100644
--- a/coworker/server/manager.py
+++ b/coworker/server/manager.py
@@ -73,6 +73,7 @@
from ..permissions import Mode
from ..agents import list_agents as _list_agents
from ..providers import (
+ LOCAL_DEFAULT_URLS,
LOCAL_PROVIDERS,
ProviderClient,
ProviderRouter,
@@ -1652,8 +1653,6 @@ def set_dm_session(self, session_id: Optional[str]) -> dict[str, Any]:
def _local_root(self, name: str) -> str:
"""Configured (or default) ROOT url for a local runtime, with any `/v1` trimmed — the
native APIs we probe below hang off the root, not the OpenAI-compatible path."""
- from ..providers.registry import LOCAL_DEFAULT_URLS
-
default = LOCAL_DEFAULT_URLS[name]
profile = self.secrets.get(f"provider:{name}") or {}
base = (profile.get("base_url") or default).strip().rstrip("/")
@@ -1699,8 +1698,10 @@ 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:
+ # `is None` (absent), NOT falsiness: connecting a local runtime on its default
+ # endpoint stores an EMPTY profile, and treating that as "never connected" left the
+ # user with a green ✓ and an empty picker.
+ if self.secrets.get("provider:ollama") is None:
return []
try:
import httpx
@@ -1721,8 +1722,7 @@ def _lmstudio_models(self) -> list[str]:
an id reliably distinguishes the two. That route is beta, so any failure (older build,
renamed path) falls back to the stable OpenAI-compatible list unfiltered.
"""
- profile = self.secrets.get("provider:lmstudio")
- if not profile:
+ if self.secrets.get("provider:lmstudio") is None: # absent, not merely empty
return []
root = self._local_root("lmstudio")
try:
diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx
index 83fd080b..f6702474 100644
--- a/surfaces/gui/src/providers/ProviderSetup.tsx
+++ b/surfaces/gui/src/providers/ProviderSetup.tsx
@@ -43,7 +43,9 @@ export const LOCAL_HELP: Record void }): ProviderSetup
setVerify({ state: "error", msg: res.error || "couldn't verify" });
return false;
}
- if (dirty || !info?.configured) await setProvider(sel, fields).catch(() => {});
+ // Keyless providers always save. `configured` is true for them from the very first
+ // render (there is no key to be missing), so the guard below would skip the save for a
+ // user who accepted the default endpoint and typed nothing — leaving no stored profile,
+ // which is exactly what model discovery reads to decide the runtime was opted into.
+ if (dirty || !info?.configured || !info?.needs_key)
+ await setProvider(sel, fields).catch(() => {});
if (!info?.needs_key) setKeylessOk((s) => new Set(s).add(sel));
setVerify({ state: "ok" });
setDirty(false);
diff --git a/tests/test_settings.py b/tests/test_settings.py
index da674d61..a24a8a9c 100644
--- a/tests/test_settings.py
+++ b/tests/test_settings.py
@@ -9,6 +9,8 @@
from pathlib import Path
+import pytest
+
from coworker.providers import resolve_api_key
from coworker.secrets import SecretStore
@@ -268,6 +270,35 @@ def boom(*_a, **_k):
assert manager._ollama_models() == []
+@pytest.mark.parametrize(
+ "name,default_url",
+ [("ollama", "http://localhost:11434"), ("lmstudio", "http://localhost:1234")],
+)
+def test_connecting_a_local_runtime_on_its_default_endpoint_enables_discovery(
+ tmp_path, monkeypatch, name, default_url
+):
+ """Connecting with the default endpoint stores an EMPTY profile — there is no required
+ field to fill in. Discovery must read that as connected (absent vs empty), or the user
+ gets a green ✓ and an empty picker."""
+ monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
+ from coworker.server.manager import SessionManager
+
+ manager = SessionManager(data_dir=tmp_path / "data")
+ probed: list[str] = []
+ monkeypatch.setattr(
+ "httpx.get", lambda url, **_k: probed.append(url) or (_ for _ in ()).throw(ConnectionError())
+ )
+
+ assert manager._suggested_models(name) == [] and not probed # unconnected → no probe
+
+ assert manager.set_provider(name, {})["ok"] is True
+ assert manager.secrets.get(f"provider:{name}") == {} # nothing to store, but present
+ manager._suggested_models(name)
+ assert probed and probed[0].startswith(default_url), (
+ f"connected {name} must be probed at its default endpoint, got {probed}"
+ )
+
+
def test_connecting_a_local_runtime_selects_a_model_it_actually_has(
tmp_path, monkeypatch
):
From c950ee57ceb87b76cbdda2ee56333b8b174999ad Mon Sep 17 00:00:00 2001
From: Buddhika Godakanda <54194807+hacksics@users.noreply.github.com>
Date: Sun, 26 Jul 2026 21:40:35 +0530
Subject: [PATCH 3/3] Use LM Studio's app icon for its provider card
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replaces the "L" monogram fallback. Unlike the other marks this is a full
app icon — it brings its own background and corner radius — so ProviderMark
renders it edge-to-edge instead of floating it at 60% inside the light
plate, which would read as a rounded square inside a rounded square.
Shortens the title to "LM Studio (local)": the longer Ollama-style form
truncated in the gallery card, and the "No key needed" line underneath
already carries the rest.
Provenance is noted in logos.ts — the SVGs come from the MIT-licensed
lobe-icons set, this one is LM Studio's own icon from lmstudio.ai.
Co-Authored-By: Claude
---
coworker/providers/registry.py | 4 +++-
surfaces/gui/src/providers/ProviderSetup.tsx | 12 +++++++---
surfaces/gui/src/providers/logos.ts | 22 +++++++++++++-----
.../gui/src/providers/logos/lmstudio.webp | Bin 0 -> 13622 bytes
4 files changed, 28 insertions(+), 10 deletions(-)
create mode 100644 surfaces/gui/src/providers/logos/lmstudio.webp
diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py
index b7781b34..18a7dc99 100644
--- a/coworker/providers/registry.py
+++ b/coworker/providers/registry.py
@@ -393,7 +393,9 @@ def _compat(
),
ProviderDescriptor(
name="lmstudio",
- title="LM Studio (local models)",
+ # "(local)" not "(local models)" like Ollama: the longer form truncates in the
+ # gallery card, and the "No key needed" line underneath already carries the rest.
+ title="LM Studio (local)",
needs_key=False,
fields=[
ProviderField(
diff --git a/surfaces/gui/src/providers/ProviderSetup.tsx b/surfaces/gui/src/providers/ProviderSetup.tsx
index f6702474..a9534fad 100644
--- a/surfaces/gui/src/providers/ProviderSetup.tsx
+++ b/surfaces/gui/src/providers/ProviderSetup.tsx
@@ -7,7 +7,7 @@ import {
type ProviderInfo,
} from "../api";
import { openExternal } from "../tauri";
-import { PROVIDER_LOGOS, providerRank } from "./logos";
+import { FULL_BLEED_LOGOS, PROVIDER_LOGOS, providerRank } from "./logos";
// The provider gallery ⇄ key form, shared by Onboarding step 1 (§39) and
// Settings ▸ Models (UX-021) so the two can never drift apart visually. The hook
@@ -54,13 +54,19 @@ export type Verify = { state: "idle" | "testing" | "ok" | "error"; msg?: string
/** Brand chip: always a light plate so multicolor marks read on any theme. */
export function ProviderMark({ name, title, size = 32 }: { name: string; title: string; size?: number }) {
const url = PROVIDER_LOGOS[name];
+ const bleed = FULL_BLEED_LOGOS.has(name);
return (
{url ? (
-
+
) : (
{title[0]}
)}
diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts
index 0b385bdd..e0028208 100644
--- a/surfaces/gui/src/providers/logos.ts
+++ b/surfaces/gui/src/providers/logos.ts
@@ -1,14 +1,19 @@
// Provider logo registry (UX-DECISIONS §39): official brand marks for the onboarding
-// provider gallery, vendored from the MIT-licensed lobe-icons set (same
-// bundled-asset posture as the connector registry — no CDN at runtime). Keys are
-// /v1/providers names; unknown names get no mark (the gallery falls back to a
-// neutral monogram). PROVIDER_ORDER is the gallery order — recognition first,
-// long tail behind the scroll fold.
+// provider gallery, bundled rather than fetched (same posture as the connector
+// registry — no CDN at runtime). Keys are /v1/providers names; unknown names get no
+// mark (the gallery falls back to a neutral monogram). PROVIDER_ORDER is the gallery
+// order — recognition first, long tail behind the scroll fold.
+//
+// Provenance: the SVGs come from the MIT-licensed lobe-icons set. `lmstudio.webp` does
+// NOT — it is LM Studio's own app icon from lmstudio.ai, used nominatively to identify
+// their product. It is also the only raster here, and unlike the flat SVG marks it
+// carries its own background, which is why ProviderMark renders it edge-to-edge.
import anthropic from "./logos/anthropic.svg";
import openai from "./logos/openai.svg";
import gemini from "./logos/gemini.svg";
import ollama from "./logos/ollama.svg";
+import lmstudio from "./logos/lmstudio.webp";
import fireworks from "./logos/fireworks.svg";
import together from "./logos/together.svg";
import zai from "./logos/zai.svg";
@@ -26,6 +31,7 @@ export const PROVIDER_LOGOS: Record = {
gemini,
meta,
ollama,
+ lmstudio,
fireworks,
together,
zai,
@@ -37,13 +43,17 @@ export const PROVIDER_LOGOS: Record = {
xai,
};
+// Marks that ARE an app icon — they bring their own background and corner radius — rather
+// than a flat glyph meant to sit on the light plate. These fill the plate edge-to-edge;
+// floating one at 60% would read as a rounded square inside a rounded square.
+export const FULL_BLEED_LOGOS = new Set(["lmstudio"]);
+
export const PROVIDER_ORDER = [
"anthropic",
"openai",
"gemini",
"meta",
// The two local runtimes sit together, right after the hosted names people recognize.
- // lmstudio has no vendored mark yet, so ProviderMark falls back to its "L" monogram.
"ollama",
"lmstudio",
"fireworks",
diff --git a/surfaces/gui/src/providers/logos/lmstudio.webp b/surfaces/gui/src/providers/logos/lmstudio.webp
new file mode 100644
index 0000000000000000000000000000000000000000..a5b29f79c8578d4fbc2ac7f5655bdf04d22e0867
GIT binary patch
literal 13622
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