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.

)} - {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..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"]