Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions coworker/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -41,4 +43,6 @@
"build_provider_client",
"detect_provider",
"verify_provider_key",
"LOCAL_PROVIDERS",
"LOCAL_DEFAULT_URLS",
]
7 changes: 4 additions & 3 deletions coworker/providers/capabilities.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
94 changes: 82 additions & 12 deletions coworker/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -24,6 +30,7 @@
from .openai_provider import OpenAIProvider

DEFAULT_OLLAMA_URL = "http://localhost:11434"
DEFAULT_LMSTUDIO_URL = "http://localhost:1234"


@dataclass(frozen=True)
Expand Down Expand Up @@ -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 `<root>/v1`.
Both local runtimes serve their OpenAI-compatible API under `/v1` while their native API
lives at the root, so we always target `<root>/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,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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__}).",
Expand All @@ -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.",
Expand Down
120 changes: 96 additions & 24 deletions coworker/server/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:<name>` 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:<id>`. 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
Expand Down Expand Up @@ -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)]
Expand Down
Loading