diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py
index 6b34c141..6fca760e 100644
--- a/coworker/providers/__init__.py
+++ b/coworker/providers/__init__.py
@@ -10,6 +10,8 @@
from .gemini_provider import GeminiProvider
from .openai_provider import OpenAIProvider, resolve_api_key
from .registry import (
+ LOCAL_DEFAULT_URLS,
+ LOCAL_PROVIDERS,
ProviderDescriptor,
ProviderField,
build_provider_client,
@@ -41,4 +43,6 @@
"build_provider_client",
"detect_provider",
"verify_provider_key",
+ "LOCAL_PROVIDERS",
+ "LOCAL_DEFAULT_URLS",
]
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..18a7dc99 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,29 @@ def _compat(
# `ollama pull qwen3-coder:30b`.
recommended_model="qwen3-coder:30b",
),
+ ProviderDescriptor(
+ name="lmstudio",
+ # "(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(
+ "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 +470,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 +485,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 +505,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 +517,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..48b2d1d2 100644
--- a/coworker/server/manager.py
+++ b/coworker/server/manager.py
@@ -73,6 +73,8 @@
from ..permissions import Mode
from ..agents import list_agents as _list_agents
from ..providers import (
+ LOCAL_DEFAULT_URLS,
+ LOCAL_PROVIDERS,
ProviderClient,
ProviderRouter,
get_descriptor,
@@ -1493,10 +1495,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 +1548,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,51 +1650,104 @@ 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."""
+ 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
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 []
- 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.
+ """
+ if self.secrets.get("provider:lmstudio") is None: # absent, not merely empty
+ 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..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
@@ -31,18 +31,42 @@ 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",
+ // The "start the server" instruction lives in the endpoint field's own help text;
+ // repeating it here put the same sentence on screen twice.
+ note: "No API key needed — LM Studio runs models on this Mac.",
+ },
+};
+
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]}
)}
@@ -156,7 +180,12 @@ 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 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);
@@ -406,14 +435,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..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,12 +43,19 @@ 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.
"ollama",
+ "lmstudio",
"fireworks",
"together",
"zai",
diff --git a/surfaces/gui/src/providers/logos/lmstudio.webp b/surfaces/gui/src/providers/logos/lmstudio.webp
new file mode 100644
index 00000000..a5b29f79
Binary files /dev/null and b/surfaces/gui/src/providers/logos/lmstudio.webp differ
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..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
@@ -174,3 +176,153 @@ 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() == []
+
+
+@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
+):
+ """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"]