Skip to content
Open
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
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

**AI that gets your everyday tasks done.** OpenWorker is an open-source AI coworker that lives on your desktop and delivers **finished work**, not just chat: a polished document, a Slack reply with the numbers, an updated calendar, a triaged inbox.

It runs on your machine and doesn't lock you into any model: bring your own API key for OpenAI, Anthropic, Google, or an open-weight provider, or run fully local with Ollama. Your data leaves your machine only through the model and integrations *you* choose.
It runs on your machine and doesn't lock you into any model: bring your own API key for OpenAI, Anthropic, Google, or an open-weight provider, or run fully local with Ollama or LM Studio. Your data leaves your machine only through the model and integrations *you* choose.

[![How OpenWorker works](docs/assets/how-it-works.png)](https://openworker.com)

Expand All @@ -20,7 +20,7 @@ It runs on your machine and doesn't lock you into any model: bring your own API
[**⬇ Windows 10/11 (x64)**](https://download.openworker.com/windows)
<sub>builds are not yet code-signed, so SmartScreen will warn; signing is in progress</sub>

Open the app, add a model key (or point it at Ollama), and ask for something real.
Open the app, add a model key (or point it at Ollama or LM Studio), and ask for something real.

## How it works

Expand Down Expand Up @@ -54,7 +54,7 @@ Under the hood:

Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box:

**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**.
**OpenAI · Anthropic · Google Gemini · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI)** - plus open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama** and **LM Studio**.

A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk.

Expand Down
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":
# Local servers (Ollama, LM Studio) host models that 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 in ("ollama", "lmstudio"):
return ModelCapabilities(
tools=True, vision=False, parallel_tool_calls=False, streaming=True
)
Expand Down
70 changes: 58 additions & 12 deletions coworker/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
`AnthropicProvider`), `gemini` (native Google GenAI API via `GeminiProvider`), `bedrock`
(models in the user's own AWS account — Claude natively, everything else via Converse),
`vertex` (the user's own GCP project — Gemini and Claude natively, open-weight via the
MaaS endpoint), and `ollama` (local, OpenAI-compatible `/v1`).
MaaS endpoint), and the local servers `ollama` and `lmstudio` (both OpenAI-compatible `/v1`).
"""

from __future__ import annotations
Expand All @@ -30,6 +30,12 @@
from .vertex_provider import VertexProvider

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

# Local, keyless providers reached through their OpenAI-compatible `/v1` — grouped where
# they need the same special-casing (the no-auth verify probe and its error copy).
LOCAL_PROVIDERS = ("ollama", "lmstudio")
_LOCAL_DEFAULT_URLS = {"ollama": DEFAULT_OLLAMA_URL, "lmstudio": DEFAULT_LMSTUDIO_URL}


@dataclass(frozen=True)
Expand Down Expand Up @@ -98,15 +104,15 @@ 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 _normalize_local_url(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`.
Ollama and LM Studio both serve their OpenAI-compatible API under `/v1` (their native
APIs live elsewhere on the same server), so we always target `<root>/v1`.
"""
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
Expand Down Expand Up @@ -184,10 +190,17 @@ def get(key: str) -> Optional[str]:
def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Ollama's OpenAI-compatible endpoint ignores the key but the SDK requires a non-empty
# string, so we pass a placeholder. `base_url` comes from the stored profile (or the default).
base_url = _normalize_ollama_url((profile or {}).get("base_url"))
base_url = _normalize_local_url((profile or {}).get("base_url"), DEFAULT_OLLAMA_URL)
return OpenAIProvider(api_key="ollama", base_url=base_url)


def _build_lmstudio(profile: dict[str, Any], secrets: Any) -> ProviderClient:
# Same placeholder-key contract as Ollama: LM Studio's local server doesn't require a
# key out of the box, but the SDK insists on a non-empty string.
base_url = _normalize_local_url((profile or {}).get("base_url"), DEFAULT_LMSTUDIO_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 @@ -569,6 +582,26 @@ 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 (the Developer tab, or "
"`lms server start`). The OpenAI-compatible /v1 path is added automatically.",
),
],
build=_build_lmstudio,
# Ollama's verified tool-calling pick, under LM Studio's catalog id — download it
# in-app or with `lms get qwen/qwen3-coder-30b`.
recommended_model="qwen/qwen3-coder-30b",
),
]

_BY_NAME = {d.name: d for d in DESCRIPTORS}
Expand Down Expand Up @@ -829,8 +862,8 @@ def verify_provider_key(
params={"key": key},
timeout=timeout,
)
elif name == "ollama":
base = _normalize_ollama_url(base_url)
elif name in LOCAL_PROVIDERS: # ollama / lmstudio: keyless local /v1
base = _normalize_local_url(base_url, _LOCAL_DEFAULT_URLS[name])
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
default_base = next(
Expand All @@ -853,12 +886,25 @@ def verify_provider_key(
}

if resp.status_code < 300:
# Local servers: a 2xx alone can't tell a model server from whatever else answers
# on that port (a dev server's HTML page, a proxy) — require the OpenAI list shape
# both serve at /v1/models. An empty model list still passes.
if name in LOCAL_PROVIDERS:
try:
shaped = isinstance(resp.json().get("data"), list)
except Exception:
shaped = False
if not shaped:
return {
"ok": False,
"error": "Reached the server, but no OpenAI-compatible /v1 API there.",
}
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
Loading