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.

)} - {info && !info.needs_key && ( + {info && !info.needs_key && LOCAL_HELP[sel] && (

- 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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