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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Model access is yours: pick a provider, paste your key, switch anytime. Supporte

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

Need another Chat Completions gateway? In **Settings → Models**, choose **Custom provider** and enter its endpoint, Bearer API key, and a short route prefix. Add the gateway's model IDs manually; for example, an OpenRouter route named `openrouter` uses `openrouter:anthropic/claude-…`. This supports standard OpenAI-compatible APIs without OpenWorker maintaining their full catalog.

## Privacy

OpenWorker is local-first. Everything lives on your machine: the agent loop, your conversations, connector tokens, and model keys - all in the app's local secret store. The only cloud piece is a small service that brokers OAuth handshakes for connectors. You can always use the App without signing-in - use the connectors via manually-created credentials/API-keys.
Expand Down
4 changes: 4 additions & 0 deletions coworker/providers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
build_provider_client,
detect_provider,
get_descriptor,
is_custom_provider,
is_removed_custom_provider,
provider_descriptors,
provider_names,
verify_provider_key,
Expand All @@ -38,6 +40,8 @@
"provider_descriptors",
"provider_names",
"get_descriptor",
"is_custom_provider",
"is_removed_custom_provider",
"build_provider_client",
"detect_provider",
"verify_provider_key",
Expand Down
135 changes: 123 additions & 12 deletions coworker/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@
from .openai_provider import OpenAIProvider

DEFAULT_OLLAMA_URL = "http://localhost:11434"
# The index deliberately lives in the SecretStore too: it names profiles which contain
# credentials, and keeping the two together makes a future secure-store backend a drop-in.
CUSTOM_PROVIDERS_PROFILE = "providers:custom"


@dataclass(frozen=True)
Expand Down Expand Up @@ -193,6 +196,50 @@ def _compat(
)


def _custom_provider_descriptor(name: str, title: str) -> ProviderDescriptor:
"""Descriptor synthesized for a user-defined OpenAI-compatible endpoint.

Unlike the built-ins there is no catalog or capability claim here. The endpoint and
bearer key are required; model ids are supplied separately by the user.
"""
return ProviderDescriptor(
name=name,
title=title,
needs_key=True,
fields=[
ProviderField("api_key", f"{title} API key", secret=True),
ProviderField(
"base_url",
"Endpoint",
required=True,
placeholder="https://…/v1",
help="An HTTPS OpenAI-compatible API base URL ending in /v1. HTTP is allowed only for local gateways.",
),
],
build=_openai_compat(title, ""),
blurb="Uses the standard OpenAI Chat Completions API. Add model IDs manually below.",
)


def _disabled_custom_provider_descriptor(name: str, title: str) -> ProviderDescriptor:
"""Keep a removed route recognizable so old sessions fail closed, never reroute."""

def build(profile: dict[str, Any], secrets: Any) -> ProviderClient:
raise RuntimeError(
f"{title} was removed from Settings ▸ Models. Configure that provider again "
"before continuing this session."
)

return ProviderDescriptor(
name=name,
title=title,
needs_key=True,
fields=[],
build=build,
blurb="Removed custom provider.",
)


DESCRIPTORS: list[ProviderDescriptor] = [
ProviderDescriptor(
name="openai",
Expand Down Expand Up @@ -357,23 +404,78 @@ def _compat(
_BY_NAME = {d.name: d for d in DESCRIPTORS}


def provider_descriptors() -> list[ProviderDescriptor]:
return list(DESCRIPTORS)


def provider_names() -> list[str]:
return [d.name for d in DESCRIPTORS]


def get_descriptor(name: str) -> Optional[ProviderDescriptor]:
return _BY_NAME.get(name)
def _custom_provider_titles(secrets: Any = None) -> dict[str, str]:
"""Return the validated custom-provider index without exposing credentials."""
if secrets is None:
return {}
raw = secrets.get(CUSTOM_PROVIDERS_PROFILE) or {}
providers = raw.get("providers") if isinstance(raw, dict) else None
if not isinstance(providers, dict):
return {}
return {
name: title
for name, title in providers.items()
if isinstance(name, str)
and isinstance(title, str)
and name not in _BY_NAME
and title.strip()
}


def _removed_custom_provider_titles(secrets: Any = None) -> dict[str, str]:
"""Routes kept as tombstones after deletion to protect existing sessions."""
if secrets is None:
return {}
raw = secrets.get(CUSTOM_PROVIDERS_PROFILE) or {}
removed = raw.get("removed") if isinstance(raw, dict) else None
if not isinstance(removed, dict):
return {}
return {
name: title
for name, title in removed.items()
if isinstance(name, str)
and isinstance(title, str)
and name not in _BY_NAME
and title.strip()
}


def is_custom_provider(name: str, secrets: Any = None) -> bool:
return name in _custom_provider_titles(secrets)


def is_removed_custom_provider(name: str, secrets: Any = None) -> bool:
return name in _removed_custom_provider_titles(secrets)


def provider_descriptors(secrets: Any = None) -> list[ProviderDescriptor]:
custom = [
_custom_provider_descriptor(name, title)
for name, title in _custom_provider_titles(secrets).items()
]
return [*DESCRIPTORS, *custom]


def provider_names(secrets: Any = None) -> list[str]:
return [d.name for d in provider_descriptors(secrets)]


def get_descriptor(name: str, secrets: Any = None) -> Optional[ProviderDescriptor]:
builtin = _BY_NAME.get(name)
if builtin is not None:
return builtin
title = _custom_provider_titles(secrets).get(name)
if title:
return _custom_provider_descriptor(name, title)
title = _removed_custom_provider_titles(secrets).get(name)
return _disabled_custom_provider_descriptor(name, title) if title else None


def build_provider_client(
name: str, profile: dict[str, Any], secrets: Any
) -> ProviderClient:
"""Build a `ProviderClient` for `name` from its stored profile. Unknown → OpenAI default."""
descriptor = _BY_NAME.get(name) or _BY_NAME["openai"]
descriptor = get_descriptor(name, secrets) or _BY_NAME["openai"]
return descriptor.build(profile or {}, secrets)


Expand All @@ -399,14 +501,15 @@ def verify_provider_key(
api_key: Optional[str] = None,
base_url: Optional[str] = None,
timeout: float = 10.0,
descriptor: Optional[ProviderDescriptor] = None,
) -> dict[str, Any]:
"""Validate a provider's credentials with one cheap, read-only call (list models) — the same
pattern connectors use to validate tokens. Transient: callers pass the key directly so a user
can Test before saving. Never raises; returns {ok, error?}.
"""
import httpx

d = _BY_NAME.get(name) or _BY_NAME["openai"]
d = descriptor or _BY_NAME.get(name) or _BY_NAME["openai"]
key = (api_key or "").strip()
try:
if name == "anthropic":
Expand Down Expand Up @@ -446,6 +549,14 @@ def verify_provider_key(

if resp.status_code < 300:
return {"ok": True}
if resp.status_code in (404, 405) and d.name not in _BY_NAME:
# Chat Completions compatibility does not require the optional model-list
# endpoint. The gateway was reached, so do not call a usable custom route
# invalid merely because it cannot provide a catalog.
return {
"ok": True,
"warning": "Endpoint reached, but it does not expose /models. Add a model ID and try a chat to finish checking it.",
}
if resp.status_code in (401, 403):
if name == "ollama":
return {"ok": False, "error": "Server rejected the request."}
Expand Down
10 changes: 5 additions & 5 deletions coworker/providers/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ def _provider_name(self, model: str) -> str:
"""
if ":" in model:
prefix = model.split(":", 1)[0]
if get_descriptor(prefix) is not None:
if get_descriptor(prefix, getattr(self, "_secrets", None)) is not None:
return prefix
return self._default

Expand All @@ -68,14 +68,14 @@ def _client_for(self, model: str) -> ProviderClient:
return client

@staticmethod
def _bare(model: str) -> str:
def _bare(model: str, secrets: Any = None) -> str:
"""Strip a KNOWN provider prefix; the underlying SDK wants the bare model name. A model
whose first segment isn't a provider (e.g. `qwen2.5-coder:32b` — a version tag, not a
prefix) is returned unchanged, so the colon isn't mistaken for a provider separator.
"""
if ":" in model:
prefix, rest = model.split(":", 1)
if get_descriptor(prefix) is not None:
if get_descriptor(prefix, secrets) is not None:
return rest
return model

Expand All @@ -98,7 +98,7 @@ def complete(
):
self._note_use(model)
return self._client_for(model).complete(
model=self._bare(model), messages=messages, tools=tools, **settings
model=self._bare(model, self._secrets), messages=messages, tools=tools, **settings
)

def stream(
Expand All @@ -111,7 +111,7 @@ def stream(
):
self._note_use(model)
return self._client_for(model).stream(
model=self._bare(model), messages=messages, tools=tools, **settings
model=self._bare(model, self._secrets), messages=messages, tools=tools, **settings
)

def capabilities(self, model: str):
Expand Down
7 changes: 7 additions & 0 deletions coworker/server/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1308,6 +1308,13 @@ def providers_set(body: dict) -> dict[str, Any]:
return {"ok": False, "error": "name required"}
return manager.set_provider(name, (body or {}).get("fields"))

@app.post("/v1/providers/custom")
def providers_custom_create(body: dict) -> dict[str, Any]:
body = body or {}
return manager.create_custom_provider(
body.get("name", ""), body.get("title", ""), body.get("fields")
)

@app.delete("/v1/providers/{name}")
def providers_remove(name: str) -> dict[str, Any]:
return manager.remove_provider(name)
Expand Down
Loading