diff --git a/README.md b/README.md index c29a8a47..a2628d1c 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,13 @@ Unattended runs never self-approve: their asks park in an inbox until a human an Model access is yours: pick a provider, paste your key, switch anytime. Supported out of the box: -**OpenAI · Azure OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Atlas Cloud · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI) · Solar (Upstage)** - plus aggregators **OrcaRouter** and **OpenRouter**, open-weight models via **Together** and **Fireworks**, and fully local models via **Ollama**. +**OpenAI · Azure OpenAI · Anthropic · Google Gemini · BytePlus Ark · Volcengine Ark Agent Plan · Atlas Cloud · Inkling (Thinking Machines) · GLM (Z.ai) · DeepSeek · Kimi (Moonshot) · Qwen · MiniMax · Mistral · Grok (xAI) · Solar (Upstage) · Cohere** - plus aggregators **OrcaRouter**, **OpenRouter** and **Hugging Face Inference Providers**, open-weight models via **Together**, **Fireworks**, **Cerebras** and **SiliconFlow**, and fully local models via **Ollama**, llama.cpp, vLLM, LM Studio, Jan, Docker Model Runner or mlx-lm. A curated model list marks what we've verified for tool-calling work. Adding any model string works at your own risk. Azure OpenAI uses your own resource: paste its endpoint and key (or set `AZURE_OPENAI_API_KEY`), then add your deployment names as models — on Azure the model id is the deployment name. +Another OpenAI-compatible server — a gateway, a proxy, a vendor not listed? In **Settings ▸ Models & Keys**, the **Add an OpenAI-compatible provider** box at the end of the API-keys group takes a name, its endpoint and key; it becomes its own card, and its models are selected as `:` (the prefix is derived from the name). Type the model ids, or fetch them from the server when it lists them. ## Privacy diff --git a/coworker/providers/__init__.py b/coworker/providers/__init__.py index d38da557..d154b4d1 100644 --- a/coworker/providers/__init__.py +++ b/coworker/providers/__init__.py @@ -19,6 +19,8 @@ from .registry import ( descriptor_configured, detect_provider, get_descriptor, + is_custom_provider, + is_removed_custom_provider, provider_descriptors, provider_names, verify_provider_key, @@ -47,6 +49,8 @@ __all__ = [ "provider_descriptors", "provider_names", "get_descriptor", + "is_custom_provider", + "is_removed_custom_provider", "build_provider_client", "descriptor_configured", "detect_provider", diff --git a/coworker/providers/capabilities.py b/coworker/providers/capabilities.py index c4198643..66a7e343 100644 --- a/coworker/providers/capabilities.py +++ b/coworker/providers/capabilities.py @@ -25,7 +25,9 @@ def capabilities_for(model: str) -> ModelCapabilities: # 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. # Vision is detected from common model naming conventions (-vl, vision, llava, etc.). - if provider in ("ollama", "llamacpp", "vllm", "lmstudio"): + from .local_server import LOCAL_SERVERS + + if provider == "ollama" or provider in LOCAL_SERVERS: _vision_patterns = ("-vl", "vision", "llava", "bakllava", "cogvlm", "minicpm-v") has_vision = any(p in name for p in _vision_patterns) return ModelCapabilities( diff --git a/coworker/providers/local_server.py b/coworker/providers/local_server.py index 51f20375..94093b8f 100644 --- a/coworker/providers/local_server.py +++ b/coworker/providers/local_server.py @@ -1,6 +1,7 @@ -"""llama.cpp, vLLM and LM Studio: servers the user starts, reached through their -OpenAI-compatible `/v1`. One module for all three, since OpenWorker treats them the same -way and they differ only in what they report about themselves. +"""llama.cpp, vLLM, LM Studio, Jan, Docker Model Runner and mlx-lm: servers the user +starts, reached through their OpenAI-compatible `/v1`. One module for all of them, since +OpenWorker treats them the same way and they differ only in what they report about +themselves. - llama.cpp `llama-server`: `localhost:8080` by default; `/v1/models` lists the loaded model (its id is the file path unless the server was started with `--alias`), with @@ -15,9 +16,16 @@ way and they differ only in what they report about themselves. `/v1/models` lists every downloaded model, and its own `/api/v0/models` adds each one's `max_context_length` and whether it is loaded. Tool calls go through the model's chat template, as in llama.cpp. +- Jan: `127.0.0.1:1337` by default (Settings ▸ Local API Server ▸ Start Server, read from + docs.jan.ai/desktop/api-server 2026-10-26); a plain OpenAI listing, an optional key. +- Docker Model Runner: `localhost:12434/engines/v1` by default (TCP host access turned on + in Docker Desktop; docs.docker.com/ai/model-runner/api-reference, read 2026-10-26). The + `/engines` segment is part of the path, so v1_base adds it when the user types the root. +- mlx-lm: `127.0.0.1:8080` by default (`mlx_lm.server`, Apple silicon only; its server.py + read 2026-10-26) — the same port as llama.cpp, so the help says to move one of them. -None of them tells the harness what the machine is; that is local_machine.py. Both may take an -API key; without one any value is accepted, and OpenWorker sends a placeholder. +None of them tells the harness what the machine is; that is local_machine.py. All may take +an API key; without one any value is accepted, and OpenWorker sends a placeholder. """ from __future__ import annotations @@ -36,6 +44,9 @@ LOCAL_SERVERS: dict[str, dict[str, str]] = { "llamacpp": {"title": "llama.cpp", "default_base": "http://localhost:8080"}, "vllm": {"title": "vLLM", "default_base": "http://localhost:8000"}, "lmstudio": {"title": "LM Studio", "default_base": "http://localhost:1234"}, + "jan": {"title": "Jan", "default_base": "http://127.0.0.1:1337"}, + "docker": {"title": "Docker Model Runner", "default_base": "http://localhost:12434/engines"}, + "mlx": {"title": "mlx-lm", "default_base": "http://127.0.0.1:8080"}, } PLACEHOLDER_KEY = "none" CACHE_SECONDS = 60.0 @@ -45,8 +56,12 @@ _lock = threading.Lock() def v1_base(name: str, base_url: Optional[str]) -> str: - """The server's `/v1` root from what the user typed: the root, or already `/v1`.""" + """The server's `/v1` root from what the user typed: the root, or already `/v1`. + Docker Model Runner serves under `/engines/v1` (or `/engines//v1`), so a bare + host:port gets the `/engines` segment too.""" base = (base_url or LOCAL_SERVERS[name]["default_base"]).strip().rstrip("/") + if name == "docker" and "/engines" not in base: + base += "/engines" return base if base.endswith("/v1") else base + "/v1" @@ -234,8 +249,9 @@ def _int(value: Any) -> Optional[int]: def thinking_as_template_kwargs(settings: dict[str, Any]) -> dict[str, Any]: """The thinking switch travels as Ollama's `think` field (model_config.py); llama.cpp, - vLLM and LM Studio read it from the chat template's `enable_thinking` instead. Rewrites one into - the other and leaves everything else alone.""" + vLLM, LM Studio and the other OpenAI-compatible local servers read it from the chat + template's `enable_thinking` instead. Rewrites one into the other and leaves everything + else alone.""" extra = settings.get("extra_body") if not isinstance(extra, dict) or "think" not in extra: return settings diff --git a/coworker/providers/matrix.py b/coworker/providers/matrix.py index 5fd31245..6ed6eeed 100644 --- a/coworker/providers/matrix.py +++ b/coworker/providers/matrix.py @@ -17,7 +17,8 @@ showing a made-up denominator. Values entered 2026-07-28 from vendor docs; verif the id refresh. Resellers: Together + Fireworks + OpenRouter + Nebius + OrcaRouter + NVIDIA NIM -+ Groq (landed 2026-10-25, after the deferral of 2026-07-04). ++ Groq (landed 2026-10-25, after the deferral of 2026-07-04) + Cerebras, Hugging Face +Inference Providers and SiliconFlow (2026-10-26). """ from __future__ import annotations @@ -221,6 +222,19 @@ MATRIX: dict[str, ModelEntry] = { # 2026-08-12 and solar-pro3 retires 2026-10-30 (docs/models/history). "upstage:solar-pro4": ModelEntry("Solar Pro 4 · Upstage", _AGENTIC, 512_000), "upstage:solar-mini4": ModelEntry("Solar Mini 4 · Upstage", _AGENTIC, 512_000), + # -- Cerebras / Hugging Face / Cohere / SiliconFlow (added 2026-10-26) -------- + # Cerebras: inference-docs.cerebras.ai/models/openai-oss — "Tool Calling" listed, + # "65k tokens (free tier), 131k tokens (paid)"; the paid window is stored. + "cerebras:gpt-oss-120b": ModelEntry("GPT-OSS 120B · via Cerebras", _AGENTIC, 131_072), + # Hugging Face Inference Providers: router.huggingface.co/v1/models (public listing) + # reports supports_tools=true and context_length 1048576 for GLM-5.2 at its providers. + "huggingface:zai-org/GLM-5.2": ModelEntry("GLM-5.2 · via Hugging Face", _AGENTIC, 1_048_576), + # Cohere: docs.cohere.com/docs/models lists command-a-plus-05-2026 at 128k; the + # tool-use guide and the Compatibility API docs use this id in their examples. + "cohere:command-a-plus-05-2026": ModelEntry("Command A+ · Cohere", _AGENTIC, 128_000), + # SiliconFlow: siliconflow.com/blog/glm-5-2-api-guide — id zai-org/GLM-5.2, "1M-token + # context window" (pricing table: 1049K); tool calling among the listed workflows. + "siliconflow:zai-org/GLM-5.2": ModelEntry("GLM-5.2 · via SiliconFlow", _AGENTIC, 1_048_576), # -- SambaNova --------------------------------------------------------------- # SambaCloud's OpenAI-compatible API; ids and windows are the `context_length` values # api.sambanova.ai/v1/models reports (keyless, read 2026-10-25). The two 128k rows are diff --git a/coworker/providers/registry.py b/coworker/providers/registry.py index d66e08e6..bfc94da3 100644 --- a/coworker/providers/registry.py +++ b/coworker/providers/registry.py @@ -27,11 +27,15 @@ from .anthropic_provider import AnthropicProvider from .base import ProviderClient from .bedrock_provider import BedrockProvider from .gemini_provider import GeminiProvider +from .local_server import LOCAL_SERVERS from .openai_provider import OpenAIProvider from .openai_responses import OpenAIResponsesProvider from .vertex_provider import VertexProvider 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) @@ -267,7 +271,8 @@ def _build_ollama(profile: dict[str, Any], secrets: Any) -> ProviderClient: def _local_server(name: str): - """Builder for llama.cpp, vLLM and LM Studio (providers/local_server.py): the user's own server, + """Builder for the OpenAI-compatible local servers (providers/local_server.py: llama.cpp, + vLLM, LM Studio, Jan, Docker Model Runner, mlx-lm): the user's own server, reached through its `/v1`, with an optional key. Without a key the server takes any value, so a placeholder goes out.""" from .local_server import PLACEHOLDER_KEY, remember_server, thinking_as_template_kwargs, v1_base @@ -436,6 +441,50 @@ def _responses_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", @@ -881,6 +930,41 @@ DESCRIPTORS: list[ProviderDescriptor] = [ recommended_model="llama-3.3-70b-versatile", env_key="GROQ_API_KEY", ), + # Four more OpenAI-compatible vendors (2026-10-26); endpoints, key pages and the + # recommended ids read from each vendor's docs that day (sources in matrix.py). + _compat( + "cerebras", + "Cerebras", + base_url="https://api.cerebras.ai/v1", + recommended_model="gpt-oss-120b", + env_key="CEREBRAS_API_KEY", + ), + # Hugging Face routes each repo id to one of its partner providers; model ids are + # the Hub repo ids, optionally with a `:provider` suffix to pin one. + _compat( + "huggingface", + "Hugging Face (Inference Providers)", + base_url="https://router.huggingface.co/v1", + recommended_model="zai-org/GLM-5.2", + env_key="HF_TOKEN", + endpoint_help="Prefilled with the Inference Providers router. Model ids are Hugging Face repo ids, e.g. zai-org/GLM-5.2.", + ), + _compat( + "cohere", + "Cohere", + base_url="https://api.cohere.ai/compatibility/v1", + recommended_model="command-a-plus-05-2026", + env_key="COHERE_API_KEY", + endpoint_help="Prefilled with Cohere's Compatibility API, which speaks the OpenAI chat format.", + ), + _compat( + "siliconflow", + "SiliconFlow", + base_url="https://api.siliconflow.com/v1", + recommended_model="zai-org/GLM-5.2", + env_key="SILICONFLOW_API_KEY", + endpoint_help="Prefilled with SiliconFlow's international endpoint. China mainland: https://api.siliconflow.cn/v1 (keys from cloud.siliconflow.cn).", + ), ProviderDescriptor( name="ollama", title="Ollama", @@ -977,28 +1061,165 @@ DESCRIPTORS: list[ProviderDescriptor] = [ blurb="The LM Studio app's local server, with whatever models you downloaded there.", kind="local", ), + # Three more OpenAI-compatible local servers (2026-10-26). Defaults read from each + # tool's own docs: docs.jan.ai/desktop/api-server (127.0.0.1:1337, /v1 prefix), + # docs.docker.com/ai/model-runner/api-reference (localhost:12434/engines/v1), + # mlx-lm's server.py (127.0.0.1:8080). No recommended model: each serves whatever the + # user loaded, and the picker lists that. + ProviderDescriptor( + name="jan", + title="Jan", + needs_key=False, + fields=[ + ProviderField( + "base_url", + "Server address", + secret=False, + required=False, + placeholder="http://127.0.0.1:1337", + help="Where Jan's local API server is listening (Settings ▸ Local API Server ▸ " + "Start Server). The /v1 path is added automatically.", + ), + ProviderField( + "api_key", + "API key (only if you set one in Jan's server settings)", + secret=True, + required=False, + ), + ], + build=_local_server("jan"), + blurb="The Jan app's local API server, with the models you downloaded there.", + kind="local", + ), + ProviderDescriptor( + name="docker", + title="Docker Model Runner", + needs_key=False, + fields=[ + ProviderField( + "base_url", + "Server address", + secret=False, + required=False, + placeholder="http://localhost:12434/engines", + help="Docker Model Runner's TCP address (enable it in Docker Desktop ▸ Settings ▸ " + "AI, or `docker desktop enable model-runner --tcp 12434`). The /engines/v1 path " + "is added automatically.", + ), + ProviderField( + "api_key", + "API key (only if the runner was started with one)", + secret=True, + required=False, + ), + ], + build=_local_server("docker"), + blurb="Models pulled with `docker model pull`, served by Docker Desktop.", + kind="local", + ), + ProviderDescriptor( + name="mlx", + title="mlx-lm", + needs_key=False, + fields=[ + ProviderField( + "base_url", + "Server address", + secret=False, + required=False, + placeholder="http://127.0.0.1:8080", + help="Where `mlx_lm.server` is listening. It defaults to port 8080, the same as " + "llama.cpp — if you run both, start one on another port " + "(`mlx_lm.server --port 8081`). The /v1 path is added automatically.", + ), + ProviderField( + "api_key", + "API key (only if the server was started with one)", + secret=True, + required=False, + ), + ], + build=_local_server("mlx"), + blurb="An mlx-lm server on Apple silicon (`pip install mlx-lm`, then `mlx_lm.server --model `).", + kind="local", + ), ] _BY_NAME = {d.name: d for d in DESCRIPTORS} -def provider_descriptors() -> list[ProviderDescriptor]: - return list(DESCRIPTORS) +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 provider_names() -> list[str]: - return [d.name for d in DESCRIPTORS] +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 get_descriptor(name: str) -> Optional[ProviderDescriptor]: - return _BY_NAME.get(name) +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) @@ -1273,6 +1494,7 @@ def verify_provider_key( base_url: Optional[str] = None, fields: Optional[dict[str, Any]] = None, timeout: float = 10.0, + descriptor: Optional[ProviderDescriptor] = None, ) -> dict[str, Any]: """Validate a provider's credentials with one cheap call — usually list models. @@ -1283,7 +1505,7 @@ def verify_provider_key( """ 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() if d.auth == "oauth": # OAuth providers verify from their stored tokens (needs the SecretStore), @@ -1330,7 +1552,7 @@ def verify_provider_key( elif name == "ollama": base = _normalize_ollama_url(base_url) resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout) - elif name in ("llamacpp", "vllm", "lmstudio"): + elif name in LOCAL_SERVERS: from .local_server import v1_base headers = {"Authorization": f"Bearer {key}"} if key else {} @@ -1420,6 +1642,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."} diff --git a/coworker/providers/router.py b/coworker/providers/router.py index c2c56c47..4a6d7f19 100644 --- a/coworker/providers/router.py +++ b/coworker/providers/router.py @@ -51,7 +51,7 @@ class ProviderRouter(ProviderClient): """ 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 @@ -68,14 +68,14 @@ class ProviderRouter(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 @@ -98,7 +98,7 @@ class ProviderRouter(ProviderClient): ): 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( @@ -111,7 +111,7 @@ class ProviderRouter(ProviderClient): ): 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): diff --git a/coworker/server/app.py b/coworker/server/app.py index c4d4f672..d32b38b5 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -2393,6 +2393,13 @@ def create_app(manager: SessionManager) -> FastAPI: return manager.set_provider(name, (body or {}).get("fields")) return await asyncio.to_thread(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}") async def providers_remove(name: str) -> dict[str, Any]: if name == "openrouter-account": diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 12a4939f..303270d7 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -8,6 +8,7 @@ sessions span folders. from __future__ import annotations import asyncio +import ipaddress import threading import json import logging @@ -21,6 +22,7 @@ import time import uuid from pathlib import Path from typing import Any, Optional +from urllib.parse import urlsplit from .. import model_config as _model_config from ..agent import build_engine @@ -84,9 +86,13 @@ from ..providers import ( ProviderRouter, descriptor_configured, get_descriptor, + is_custom_provider, + is_removed_custom_provider, provider_descriptors, verify_provider_key, ) +from ..providers.local_server import LOCAL_SERVERS +from ..providers.registry import CUSTOM_PROVIDERS_PROFILE from ..secrets import SecretStore, state_dir from ..sessions import SessionRecord, usage_totals from ..teams import Actor as TeamActor @@ -115,6 +121,28 @@ _SCOPES = {s.value for s in Scope} logger = logging.getLogger("coworker.manager") +def _custom_endpoint_error(url: str) -> Optional[str]: + """Allow HTTPS everywhere; plaintext HTTP only for an explicitly local gateway.""" + try: + parsed = urlsplit((url or "").strip()) + except ValueError: + return "Enter a valid HTTPS endpoint." + if parsed.scheme == "https" and parsed.hostname: + return None + if parsed.scheme != "http" or not parsed.hostname: + return "Endpoint must be an HTTPS URL (for example, https://gateway.example/v1)." + host = parsed.hostname.lower() + if host == "localhost": + return None + try: + address = ipaddress.ip_address(host) + except ValueError: + return "HTTP endpoints are allowed only for localhost or a private IP address." + if address.is_loopback or address.is_private: + return None + return "HTTP endpoints are allowed only for localhost or a private IP address." + + def _grants_of(engine) -> dict[str, Any]: """The engine's session-scoped "Always allow" approvals, in persistable shape.""" tools = sorted(getattr(engine.permissions, "session_allow_tools", None) or ()) @@ -4162,7 +4190,7 @@ class SessionManager: non-secret field values (e.g. the Ollama base URL) ARE returned so the form can prefill. """ out: list[dict[str, Any]] = [] - for d in provider_descriptors(): + for d in provider_descriptors(self.secrets): profile = self.secrets.get(f"provider:{d.name}") or {} configured = descriptor_configured(d, profile) values = { @@ -4189,6 +4217,7 @@ class SessionManager: "last_used_at": (self._prefs.get("provider_last_used") or {}).get( d.name ), + "is_custom": is_custom_provider(d.name, self.secrets), } if d.kind == "local": # A keyless server is "connected" when it answers (cached probes), not @@ -4207,6 +4236,51 @@ class SessionManager: out.append(row) return out + def create_custom_provider( + self, name: str, title: str, fields: Optional[dict[str, Any]] + ) -> dict[str, Any]: + """Create a named OpenAI-compatible provider, then save its first credentials. + + The short slug is the stable model-route prefix (``slug:model-id``), so it is + deliberately immutable and restricted to a transport-safe subset. + """ + name = (name or "").strip().lower() + title = (title or "").strip() + if not re.fullmatch(r"[a-z][a-z0-9_-]{0,31}", name): + return { + "ok": False, + "error": "Route prefix must start with a letter and use only lowercase letters, numbers, - or _.", + } + if not title or len(title) > 80: + return {"ok": False, "error": "Enter a provider name (up to 80 characters)."} + if get_descriptor(name) is not None or is_custom_provider(name, self.secrets): + return {"ok": False, "error": "That route prefix is already in use."} + fields = fields or {} + base_url = str(fields.get("base_url") or "").strip() + endpoint_error = _custom_endpoint_error(base_url) + if endpoint_error: + return {"ok": False, "error": endpoint_error} + if not str(fields.get("api_key") or "").strip(): + return {"ok": False, "error": "Enter an API key."} + + index = dict(self.secrets.get(CUSTOM_PROVIDERS_PROFILE) or {}) + providers = dict(index.get("providers") or {}) + removed = dict(index.get("removed") or {}) + previous_removed = dict(removed) + providers[name] = title + removed.pop(name, None) # re-creating a route clears its fail-closed tombstone + self.secrets.put( + CUSTOM_PROVIDERS_PROFILE, {"providers": providers, "removed": removed} + ) + saved = self.set_provider(name, fields) + if not saved.get("ok"): + providers.pop(name, None) + self.secrets.put( + CUSTOM_PROVIDERS_PROFILE, + {"providers": providers, "removed": previous_removed}, + ) + return saved + def pick_native_folder(self) -> dict[str, Any]: """Open the OS folder picker FROM THE SIDECAR — the browser GUI can't obtain absolute paths from web file dialogs, but the sidecar is local and can (the desktop shell uses @@ -4280,6 +4354,11 @@ class SessionManager: ], "atlascloud": ["zai-org/glm-5.2"], "upstage": ["solar-pro4", "solar-mini4"], + # Verified 2026-10-26 (sources in providers/matrix.py). + "cerebras": ["gpt-oss-120b"], + "huggingface": ["zai-org/GLM-5.2"], + "cohere": ["command-a-plus-05-2026"], + "siliconflow": ["zai-org/GLM-5.2"], } def _suggested_models(self, name: str) -> list[str]: @@ -4288,7 +4367,7 @@ class SessionManager: topped up with the compat-vendor extras the matrix doesn't vouch for.""" if name == "ollama": return [m.split(":", 1)[-1] for m in self._ollama_models()] - if name in ("llamacpp", "vllm", "lmstudio"): + if name in LOCAL_SERVERS: return [m.split(":", 1)[-1] for m in self._local_server_models(name)] from ..providers.matrix import models_for_provider @@ -4303,9 +4382,11 @@ class SessionManager: ) -> dict[str, Any]: """Store a provider's config in its `provider:` SecretStore profile and rebuild its cached client. Merges provided fields into any existing profile.""" - d = get_descriptor(name) + d = get_descriptor(name, self.secrets) if d is None: return {"ok": False, "error": f"unknown provider: {name}"} + if is_removed_custom_provider(name, self.secrets): + return {"ok": False, "error": "provider was removed; create it again first"} fields = fields or {} profile = dict(self.secrets.get(f"provider:{name}") or {}) for f in d.fields: @@ -4321,6 +4402,10 @@ class SessionManager: missing = [f.label for f in d.fields if f.required and not profile.get(f.key)] if missing: return {"ok": False, "error": "missing: " + ", ".join(missing)} + if is_custom_provider(name, self.secrets): + endpoint_error = _custom_endpoint_error(str(profile.get("base_url") or "")) + if endpoint_error: + return {"ok": False, "error": endpoint_error} # A (re)pasted key stamps its save date — Settings shows "key added " so stale # keys are visible. Endpoint-only saves keep the original stamp. if isinstance(fields.get("api_key"), str) and fields["api_key"].strip(): @@ -4339,7 +4424,7 @@ class SessionManager: fresh-sandbox gpt-5.6-sol case; owner-hit on a sandbox 2026-09-02). A default that already works is never stolen. Returns the model adopted, if any.""" - d = get_descriptor(name) + d = get_descriptor(name, self.secrets) if d is None: return None self._refresh_provider(name) @@ -4351,7 +4436,7 @@ class SessionManager: if rec and rec in suggested: # OpenAI models stay bare (the router's default); others carry their prefix. added = rec if name == "openai" else f"{name}:{rec}" - elif name in ("ollama", "llamacpp", "vllm", "lmstudio") and suggested: + elif (name == "ollama" or name in LOCAL_SERVERS) and suggested: # A local server offers whatever the user loaded — the recommended model may # not be one. added = f"{name}:{suggested[0]}" @@ -4366,7 +4451,7 @@ class SessionManager: """Forget a provider's stored config (Settings ▸ Models "Remove key"). The whole `provider:` profile goes — key, endpoint, key_set_at — so the provider reads as never configured. Curated models stay; they just gray out until a new key.""" - d = get_descriptor(name) + d = get_descriptor(name, self.secrets) if d is None: return {"ok": False, "error": f"unknown provider: {name}"} env_var = d.env_key if (d.env_key and os.environ.get(d.env_key)) else None @@ -4385,6 +4470,31 @@ class SessionManager: ), } self.secrets.delete(f"provider:{name}") + if is_custom_provider(name, self.secrets): + index = dict(self.secrets.get(CUSTOM_PROVIDERS_PROFILE) or {}) + providers = dict(index.get("providers") or {}) + removed = dict(index.get("removed") or {}) + title = providers.pop(name, d.title) + removed[name] = title + self.secrets.put( + CUSTOM_PROVIDERS_PROFILE, + {"providers": providers, "removed": removed}, + ) + # A deleted custom route must not remain selectable and accidentally fall + # through to the default OpenAI provider as an unknown ``prefix:model``. + prefix = name + ":" + self._prefs["models"] = [ + m for m in self._prefs.get("models", []) if not str(m).startswith(prefix) + ] + self._prefs["hidden_models"] = [ + m + for m in self._prefs.get("hidden_models", []) + if not str(m).startswith(prefix) + ] + if str(self.model).startswith(prefix): + self.model = "gpt-5.6-sol" + self._prefs["default_model"] = self.model + self._save_prefs() self._refresh_provider(name) out: dict[str, Any] = {"ok": True, "provider": name} if env_var: @@ -4453,7 +4563,7 @@ class SessionManager: a field blank (e.g. testing an already-configured provider).""" import os - d = get_descriptor(name) + d = get_descriptor(name, self.secrets) if d is None: return {"ok": False, "error": f"unknown provider: {name}"} if d.auth == "oauth": @@ -4482,20 +4592,27 @@ class SessionManager: missing = [f.label for f in d.fields if f.required and not merged.get(f.key)] if missing: return {"ok": False, "error": "missing: " + ", ".join(missing)} + # A verification call sends the key to the supplied endpoint too. Apply the + # same transport policy as Save so an unsaved edit cannot accidentally + # send a credential over plaintext to a remote host. + if is_custom_provider(name, self.secrets): + endpoint_error = _custom_endpoint_error(merged.get("base_url", "")) + if endpoint_error: + return {"ok": False, "error": endpoint_error} return verify_provider_key( - name, api_key=api_key, base_url=merged.get("base_url", ""), fields=merged + name, api_key=api_key, base_url=merged.get("base_url", ""), fields=merged, descriptor=d ) def _model_provider(self, model: str) -> str: """The provider a model string routes to (known `prefix:` or the OpenAI default).""" if ":" in (model or ""): prefix = model.split(":", 1)[0] - if get_descriptor(prefix) is not None: + if get_descriptor(prefix, self.secrets) is not None: return prefix return "openai" def _provider_configured(self, name: str) -> bool: - d = get_descriptor(name) + d = get_descriptor(name, self.secrets) if d is None: return False return descriptor_configured(d, self.secrets.get(f"provider:{name}") or {}) @@ -4587,10 +4704,63 @@ class SessionManager: profile = self.secrets.get(f"provider:{name}") or {} rows = local_server.model_facts(name, profile.get("base_url"), profile.get("api_key"), fresh=True) return {"provider": name, "models": rows, "alive": self._local_server_alive(name)} + if is_custom_provider(name, self.secrets): + return self._custom_provider_models(name) return {"provider": name, "models": [], "error": "no local model list for this provider"} + def _custom_provider_models(self, name: str) -> dict[str, Any]: + """The ids a user-added OpenAI-compatible provider lists at GET /models, as rows the + settings page can tick ("Fetch from server"). A server without /models is not a + broken provider — Chat Completions does not require the listing — so that case + comes back as a note, and the user types the ids instead.""" + import httpx + + profile = self.secrets.get(f"provider:{name}") or {} + base = str(profile.get("base_url") or "").strip().rstrip("/") + key = str(profile.get("api_key") or "").strip() + if not base: + return {"provider": name, "models": [], "error": "No endpoint saved for this provider."} + try: + resp = httpx.get( + base + "/models", + headers={"Authorization": f"Bearer {key}"} if key else {}, + timeout=8.0, + ) + except Exception as exc: # noqa: BLE001 - a listing never raises into the route + return { + "provider": name, + "models": [], + "alive": False, + "error": f"Could not reach {base}: {exc.__class__.__name__}.", + } + if resp.status_code in (404, 405): + return { + "provider": name, + "models": [], + "alive": True, + "error": "This server does not list its models (no /models). Type the model ids by hand.", + } + if resp.status_code >= 300: + return {"provider": name, "models": [], "alive": True, "error": f"HTTP {resp.status_code} from {base}/models."} + try: + data = resp.json() + except ValueError: + return {"provider": name, "models": [], "alive": True, "error": f"{base}/models did not answer with JSON."} + items = data.get("data") if isinstance(data, dict) else data + ids = [str(it["id"]) for it in items if isinstance(it, dict) and it.get("id")] if isinstance(items, list) else [] + rows = [ + { + "model": f"{name}:{mid}", "name": mid, "size_bytes": None, "tools": None, + "thinking": None, "vision": None, "remote": True, "parameter_size": None, + "quantization": None, "context_max": None, "context": None, + "context_from": "server", "fit": "cloud", "recommendation": None, + } + for mid in ids + ] + return {"provider": name, "models": rows, "alive": True} + def _local_server_alive(self, name: str) -> bool: - """Best-effort liveness of a llama.cpp, vLLM or LM Studio server, cached 30 s like Ollama's.""" + """Best-effort liveness of an OpenAI-compatible local server (local_server.LOCAL_SERVERS), cached 30 s like Ollama's.""" import time from ..providers import local_server @@ -4607,7 +4777,7 @@ class SessionManager: return alive def _local_server_models(self, name: str) -> list[str]: - """The model ids a llama.cpp, vLLM or LM Studio server serves, as `:`; empty when + """The model ids a local server (local_server.LOCAL_SERVERS) serves, as `:`; empty when it does not answer or was started without tool calling.""" from ..providers import local_server @@ -4631,7 +4801,7 @@ class SessionManager: provider = self._model_provider(model) if provider == "ollama": return self._ollama_alive() - if provider in ("llamacpp", "vllm", "lmstudio"): + if provider in LOCAL_SERVERS: return self._local_server_alive(provider) return self._provider_configured(provider) @@ -4864,7 +5034,7 @@ class SessionManager: # Pulled Ollama models are offered live while Ollama answers — no need to add each one. if self._ollama_alive(): selectable = list(dict.fromkeys([*selectable, *self._ollama_models()])) - for local in ("llamacpp", "vllm", "lmstudio"): + for local in LOCAL_SERVERS: if self.secrets.get(f"provider:{local}") and self._local_server_alive(local): selectable = list(dict.fromkeys([*selectable, *self._local_server_models(local)])) if self.model not in selectable: diff --git a/docs/assets/models-custom-provider-dark.png b/docs/assets/models-custom-provider-dark.png new file mode 100644 index 00000000..e6602c77 Binary files /dev/null and b/docs/assets/models-custom-provider-dark.png differ diff --git a/docs/assets/models-custom-provider-form-dark.png b/docs/assets/models-custom-provider-form-dark.png new file mode 100644 index 00000000..d2aa31f4 Binary files /dev/null and b/docs/assets/models-custom-provider-form-dark.png differ diff --git a/docs/assets/models-custom-provider-form.png b/docs/assets/models-custom-provider-form.png new file mode 100644 index 00000000..19ef56ee Binary files /dev/null and b/docs/assets/models-custom-provider-form.png differ diff --git a/docs/assets/models-custom-provider.png b/docs/assets/models-custom-provider.png new file mode 100644 index 00000000..a64dc3bb Binary files /dev/null and b/docs/assets/models-custom-provider.png differ diff --git a/docs/assets/models-local-servers-dark.png b/docs/assets/models-local-servers-dark.png new file mode 100644 index 00000000..69a84e3f Binary files /dev/null and b/docs/assets/models-local-servers-dark.png differ diff --git a/docs/assets/models-local-servers.png b/docs/assets/models-local-servers.png new file mode 100644 index 00000000..1b8eb142 Binary files /dev/null and b/docs/assets/models-local-servers.png differ diff --git a/docs/local-servers.md b/docs/local-servers.md index f5a4addb..713e5e15 100644 --- a/docs/local-servers.md +++ b/docs/local-servers.md @@ -1,8 +1,10 @@ -# llama.cpp, vLLM and LM Studio +# llama.cpp, vLLM, LM Studio, Jan, Docker Model Runner and mlx-lm OpenWorker can use a model served by [llama.cpp](https://github.com/ggml-org/llama.cpp), -[vLLM](https://github.com/vllm-project/vllm) or [LM Studio](https://lmstudio.ai), on this -computer or on another machine. Each is its own provider in Settings ▸ Models & Keys, under "On your own hardware", next +[vLLM](https://github.com/vllm-project/vllm), [LM Studio](https://lmstudio.ai), +[Jan](https://jan.ai), [Docker Model Runner](https://docs.docker.com/ai/model-runner/) or +[mlx-lm](https://github.com/ml-explore/mlx-lm), on this computer or on another machine. +Each is its own provider in Settings ▸ Models & Keys, under "On your own hardware", next to Ollama. Give it the server's address and, only if the server was started with one, its API key. OpenWorker reads the rest from the server. @@ -57,9 +59,50 @@ first request names it. The default address is `http://localhost:1234`. +## Jan + +Download a model in the Jan app, then go to Settings ▸ Local API Server, pick the model +and click Start Server. Jan listens on `127.0.0.1:1337` and serves the OpenAI API under +`/v1`. + +- A key is needed only if you set one in the server's configuration. + +The default address is `http://127.0.0.1:1337`. + +## Docker Model Runner + +Turn on Model Runner in Docker Desktop (Settings ▸ AI), with TCP host access, then pull a +model: + +```bash +docker model pull hf.co/: +``` + +- The OpenAI API is served under `/engines/v1` on port 12434; typing + `http://localhost:12434` or `http://localhost:12434/engines` both work. +- The model's id is the name you pulled (`hf.co/...` or `ai/...`). + +The default address is `http://localhost:12434/engines`. + +## mlx-lm + +mlx-lm runs on Apple silicon: + +```bash +pip install mlx-lm +mlx_lm.server --model mlx-community/ +``` + +- It listens on `127.0.0.1:8080`, the same port as llama.cpp. If you run both, start one + of them on another port, for example `mlx_lm.server --port 8081`, and type that address. +- The model's id is the repo you started it with. + +The default address is `http://127.0.0.1:8080`. + ## In the CLI -The model id is `llamacpp:`, `vllm:` or `lmstudio:`: +The model id is `:` — `llamacpp:`, `vllm:`, `lmstudio:`, +`jan:`, `docker:` or `mlx:`: ```bash openworker run --model llamacpp:my-model --prompt "..." diff --git a/surfaces/gui/e2e/custom-provider.spec.ts b/surfaces/gui/e2e/custom-provider.spec.ts new file mode 100644 index 00000000..e17de03f --- /dev/null +++ b/surfaces/gui/e2e/custom-provider.spec.ts @@ -0,0 +1,68 @@ +// Settings ▸ Models & Keys: the permanent "Add an OpenAI-compatible provider" box at the end +// of the API-keys group (PR #224 design, owner follow-up 2026-10-26). Saving it creates a card +// named by the user; the card's models are typed or fetched from the server; removing the +// provider removes the card. The add box stays through all of it. +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +async function openModels(page) { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models & Keys" }).click(); + await expect(page.getByTestId("set-provider-openai")).toBeVisible(); +} + +test("the add box makes a named card, which lists its server's models and can be removed", async ({ page }) => { + await openModels(page); + // The box sits in the API-keys group, visible without expanding the fold. + const addBox = page.getByTestId("provider-grid-api_key").getByTestId("set-provider-custom-add"); + await expect(addBox).toContainText("Add an OpenAI-compatible provider"); + await addBox.click(); + + await page.getByTestId("custom-provider-title").fill("Acme Gateway"); + await expect(page.getByTestId("custom-provider-route")).toHaveValue("acme-gateway"); + await page.getByTestId("custom-provider-endpoint").fill("https://llm.acme.example/v1"); + await page.getByTestId("custom-provider-key").fill("acme-test"); + await page.getByTestId("custom-provider-add").click(); + + // The new card opened: the user's name on it, the endpoint shown, "Remove provider…" offered. + await expect(page.locator("main")).toContainText("Acme Gateway"); + await expect(page.getByTestId("set-field-base_url")).toHaveValue("https://llm.acme.example/v1"); + await expect(page.getByTestId("set-remove-key")).toContainText("Remove provider"); + + // Fetch from server lists the ids; ticking one adds it under the route prefix. + await page.getByTestId("custom-fetch-models").click(); + await expect(page.getByTestId("custom-fetch-note")).toContainText("2 models listed"); + const added = page.waitForRequest((r) => r.url().endsWith("/v1/settings/models/add") && r.method() === "POST"); + await page.locator(".mlist-row", { hasText: "acme-large" }).getByRole("checkbox").click(); + expect((await added).postDataJSON()).toEqual({ model: "acme-gateway:acme-large" }); + + // Back in the gallery: the card wears the name, and the add box is still there. + await page.getByTestId("set-back").click(); + const card = page.getByTestId("set-provider-acme-gateway"); + await expect(card).toContainText("Acme Gateway"); + await expect(card).toContainText("✓ Connected"); + await expect(page.getByTestId("set-provider-custom-add")).toBeVisible(); + + // Remove provider… takes the card away. + page.once("dialog", (d) => d.accept()); + await card.click(); + await page.getByTestId("set-remove-key").click(); + await expect(page.getByTestId("set-provider-acme-gateway")).toHaveCount(0); + await expect(page.getByTestId("set-provider-custom-add")).toBeVisible(); +}); + +test("a built-in provider's name is refused as a route prefix", async ({ page }) => { + await openModels(page); + await page.getByTestId("set-provider-custom-add").click(); + await page.getByTestId("custom-provider-title").fill("OpenAI"); + await page.getByTestId("custom-provider-endpoint").fill("https://proxy.example/v1"); + await page.getByTestId("custom-provider-key").fill("k"); + await page.getByTestId("custom-provider-add").click(); + await expect(page.getByTestId("custom-provider-error")).toContainText("already in use"); + // The form stays; the user picks another prefix. + await page.getByTestId("custom-provider-route").fill("my-openai-proxy"); + await page.getByTestId("custom-provider-add").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue("https://proxy.example/v1"); +}); diff --git a/surfaces/gui/e2e/docs-screenshots.spec.ts b/surfaces/gui/e2e/docs-screenshots.spec.ts new file mode 100644 index 00000000..b9fec34a --- /dev/null +++ b/surfaces/gui/e2e/docs-screenshots.spec.ts @@ -0,0 +1,90 @@ +// Screenshots for docs/assets and PR bodies, light and dark, from the hermetic mock. +// Opt-in: `DOCS_SHOTS=1 npx playwright test e2e/docs-screenshots.spec.ts` writes the PNGs; +// a normal run skips this file. +import path from "node:path"; +import { expect } from "@playwright/test"; +import { test } from "./fixtures"; + +const OUT = process.env.DOCS_SHOTS_DIR || path.resolve(process.cwd(), "../../docs/assets"); +test.skip(!process.env.DOCS_SHOTS, "set DOCS_SHOTS=1 to refresh the docs screenshots"); + +const localServer = (name: string, title: string, placeholder: string) => ({ + name, title, kind: "local", needs_key: false, configured: false, values: {}, suggested_models: [], recommended_model: null, + fields: [ + { key: "base_url", label: "Server address", secret: false, required: false, help: "", placeholder }, + { key: "api_key", label: "API key (only if the server was started with one)", secret: true, required: false, help: "", placeholder: "" }, + ], +}); + +/** Every image decoded and the fonts in: a card's SVG mark can otherwise be captured + * before it paints (the vLLM mark came out blank once). */ +async function painted(page) { + await page.evaluate(async () => { + await Promise.all( + Array.from(document.images).map((img) => + img.complete ? img.decode().catch(() => {}) : new Promise((done) => { img.onload = img.onerror = () => done(); }), + ), + ); + await (document as any).fonts?.ready; + }); +} + +async function openModels(page) { + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await page.getByRole("button", { name: "Models & Keys" }).click(); + await expect(page.getByTestId("provider-groups")).toBeVisible(); +} + +for (const scheme of ["light", "dark"] as const) { + const suffix = scheme === "dark" ? "-dark" : ""; + + test(`custom provider: add box, saved card, form (${scheme})`, async ({ page }) => { + await page.emulateMedia({ colorScheme: scheme }); + await page.setViewportSize({ width: 1280, height: 900 }); + await openModels(page); + await page.getByTestId("set-provider-custom-add").click(); + await page.getByTestId("custom-provider-title").fill("Acme Gateway"); + await page.getByTestId("custom-provider-endpoint").fill("https://llm.acme.example/v1"); + await page.getByTestId("custom-provider-key").fill("acme-test"); + await painted(page); + await page.screenshot({ path: path.join(OUT, `models-custom-provider-form${suffix}.png`) }); + await page.getByTestId("custom-provider-add").click(); + await expect(page.getByTestId("set-field-base_url")).toHaveValue("https://llm.acme.example/v1"); + await page.getByTestId("set-back").click(); + await expect(page.getByTestId("set-provider-acme-gateway")).toBeVisible(); + await painted(page); + await page.screenshot({ path: path.join(OUT, `models-custom-provider${suffix}.png`) }); + }); + + test(`local servers group (${scheme})`, async ({ page }) => { + await page.route("**/v1/providers", (route) => + route.fulfill({ + json: [ + // The cloud rows the default fixture carries, so the picker card below names + // the vendors the same way as in the other scene. + { name: "openai", title: "OpenAI", needs_key: true, configured: true, values: {}, suggested_models: ["gpt-5.5"], recommended_model: null, key_set_at: "2026-06-12", last_used_at: null, fields: [{ key: "api_key", label: "OpenAI API key", secret: true, required: true, help: "", placeholder: "sk-…" }] }, + { name: "anthropic", title: "Claude (Anthropic)", needs_key: true, configured: true, values: {}, suggested_models: ["claude-opus-4-8"], recommended_model: null, key_set_at: null, last_used_at: null, fields: [{ key: "api_key", label: "API key", secret: true, required: true, help: "", placeholder: "sk-…" }] }, + { name: "ollama", title: "Ollama", kind: "local", needs_key: false, configured: true, values: {}, suggested_models: [], recommended_model: null, alive: true, fields: [] }, + localServer("llamacpp", "llama.cpp", "http://localhost:8080"), + localServer("vllm", "vLLM", "http://localhost:8000"), + localServer("lmstudio", "LM Studio", "http://localhost:1234"), + localServer("jan", "Jan", "http://127.0.0.1:1337"), + localServer("docker", "Docker Model Runner", "http://localhost:12434/engines"), + localServer("mlx", "mlx-lm", "http://127.0.0.1:8080"), + ], + }), + ); + await page.emulateMedia({ colorScheme: scheme }); + await page.setViewportSize({ width: 1280, height: 900 }); + await openModels(page); + await expect(page.getByTestId("set-provider-mlx")).toBeVisible(); + // Seven local servers: open the fold so every row is in the picture. + const more = page.getByTestId("provider-more-local"); + if (await more.isVisible()) await more.click(); + await expect(page.getByTestId("set-provider-vllm")).toBeVisible(); + await painted(page); + await page.screenshot({ path: path.join(OUT, `models-local-servers${suffix}.png`) }); + }); +} diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 8f19715c..40ecedbf 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -2029,6 +2029,36 @@ export async function mockApi(page: import("@playwright/test").Page) { ? json({ ok: false, error: "Invalid API key." }) : json({ ok: true }); } + // a user-added OpenAI-compatible provider (backend create_custom_provider parity): the + // slug is checked, a built-in's name is refused, the card appears configured. + if (p.endsWith("/v1/providers/custom") && m === "POST") { + const b = req.postDataJSON(); + const name = String(b.name || "").toLowerCase(); + if (!/^[a-z][a-z0-9_-]{0,31}$/.test(name)) + return json({ ok: false, error: "Route prefix must start with a letter and use only lowercase letters, numbers, - or _." }); + if (providers.some((x) => x.name === name)) return json({ ok: false, error: "That route prefix is already in use." }); + providers.push({ + name, title: String(b.title || name), needs_key: true, is_custom: true, kind: "api_key", + blurb: "Uses the standard OpenAI Chat Completions API. Add model IDs manually below.", + fields: [ + { key: "api_key", label: `${b.title} API key`, secret: true, required: true, help: "", placeholder: "" }, + { key: "base_url", label: "Endpoint", secret: false, required: true, help: "An HTTPS OpenAI-compatible API base URL ending in /v1. HTTP is allowed only for local gateways.", placeholder: "https://…/v1" }, + ], + configured: true, values: { base_url: b.fields?.base_url }, suggested_models: [], recommended_model: null, + key_set_at: "2026-10-26", last_used_at: null, + }); + return json({ ok: true, provider: name, recommended_model: null }); + } + // a custom provider's "Fetch from server": what its GET /models lists. + if (/\/v1\/providers\/[^/]+\/models$/.test(p) && m === "GET") { + const name = p.split("/").slice(-2)[0]; + const prov = providers.find((x) => x.name === name); + if (prov?.is_custom) { + const row = (id: string) => ({ model: `${name}:${id}`, name: id, size_bytes: null, tools: null, thinking: null, vision: null, remote: true, parameter_size: null, quantization: null, context_max: null, context: null, context_from: "server", fit: "cloud", recommendation: null }); + return json({ provider: name, alive: true, models: [row("acme-large"), row("acme-small")] }); + } + return json({ provider: name, models: [], error: "no local model list for this provider" }); + } // save a provider key — flips `configured`, stamps key_set_at (backend set_provider parity). if (p.endsWith("/v1/providers") && m === "POST") { const b = req.postDataJSON(); @@ -2051,6 +2081,11 @@ export async function mockApi(page: import("@playwright/test").Page) { const name = p.split("/").pop()!; const prov = providers.find((x) => x.name === name); if (!prov) return json({ ok: false, error: `unknown provider: ${name}` }); + if (prov.is_custom) { + // Removing a custom provider removes the card itself (backend remove_provider parity). + providers.splice(providers.indexOf(prov), 1); + return json({ ok: true, provider: name }); + } prov.configured = !prov.needs_key; // keyless (ollama) stays "configured" prov.key_set_at = null; return json({ ok: true, provider: name }); diff --git a/surfaces/gui/e2e/local-server-help.spec.ts b/surfaces/gui/e2e/local-server-help.spec.ts index b839633b..d51001ba 100644 --- a/surfaces/gui/e2e/local-server-help.spec.ts +++ b/surfaces/gui/e2e/local-server-help.spec.ts @@ -10,6 +10,9 @@ const server = (name: string, title: string) => ({ for (const [name, title, says, link] of [ ["llamacpp", "llama.cpp", "llama-server", "Install llama.cpp"], ["vllm", "vLLM", "NVIDIA GPU", "Install vLLM"], + ["jan", "Jan", "Local API Server", "Set up Jan's API server"], + ["docker", "Docker Model Runner", "docker model pull", "Set up Docker Model Runner"], + ["mlx", "mlx-lm", "--port 8081", "Set up mlx-lm"], ] as const) { test(`${title}: its own help line, not Ollama's`, async ({ page }) => { await page.route("**/v1/providers", (route) => route.fulfill({ json: [server(name, title)] })); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index 1a9cf81b..fe5b0df1 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -2517,6 +2517,7 @@ export interface ProviderInfo { // How Models & Keys groups it: a server on the user's own hardware, a plan signed // into, or an API key. Absent on an older backend → treated as an API key. kind?: "local" | "subscription" | "api_key"; + is_custom?: boolean; // user-created OpenAI-compatible provider // Local servers only: whether the server answers right now (cached probe). alive?: boolean; signed_in?: boolean; @@ -2737,6 +2738,20 @@ export async function setProvider( return res.json(); } +/** Create a named, user-configured OpenAI-compatible provider. */ +export async function createCustomProvider( + name: string, + title: string, + fields: Record, +): Promise<{ ok: boolean; error?: string; provider?: string }> { + const res = await fetch(`${httpBase()}/v1/providers/custom`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, title, fields }), + }); + return res.json(); +} + /** Forget a provider's stored config (Settings ▸ Models "Remove key…"). */ export async function removeProvider(name: string): Promise<{ ok: boolean; error?: string }> { const res = await fetch(`${httpBase()}/v1/providers/${encodeURIComponent(name)}`, { @@ -2749,7 +2764,7 @@ export async function removeProvider(name: string): Promise<{ ok: boolean; error export async function verifyProvider( name: string, fields: Record, -): Promise<{ ok: boolean; error?: string }> { +): Promise<{ ok: boolean; error?: string; warning?: string }> { const res = await fetch(`${httpBase()}/v1/providers/verify`, { method: "POST", headers: { "Content-Type": "application/json" }, diff --git a/surfaces/gui/src/components/ManageTabs.test.tsx b/surfaces/gui/src/components/ManageTabs.test.tsx new file mode 100644 index 00000000..18a2251b --- /dev/null +++ b/surfaces/gui/src/components/ManageTabs.test.tsx @@ -0,0 +1,100 @@ +// The "Add an OpenAI-compatible provider" box (PR #224 design, owner follow-up): the route +// prefix follows the name until edited, saving creates a card named by the user and opens +// it, and "Fetch from server" offers the ids the server lists. +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { ModelsTab, routePrefixFor } from "./ManageTabs"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +it("derives the route prefix from the name", () => { + expect(routePrefixFor("Acme Gateway")).toBe("acme-gateway"); + expect(routePrefixFor(" My LLM proxy #2 ")).toBe("my-llm-proxy-2"); + expect(routePrefixFor("42 Labs")).toBe("labs"); + expect(routePrefixFor("x".repeat(40))).toHaveLength(32); +}); + +function stubApi() { + let created = false; + const calls: Array<{ url: string; method: string; body?: unknown }> = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + const method = (init?.method || "GET").toUpperCase(); + calls.push({ url, method, body: init?.body ? JSON.parse(String(init.body)) : undefined }); + if (url.includes("/v1/providers/custom") && method === "POST") { + created = true; + return { json: async () => ({ ok: true, provider: "acme-gateway" }) } as Response; + } + if (url.includes("/v1/providers/acme-gateway/models")) { + return { + ok: true, + json: async () => ({ + provider: "acme-gateway", + alive: true, + models: [ + { model: "acme-gateway:acme-large", name: "acme-large" }, + { model: "acme-gateway:acme-small", name: "acme-small" }, + ], + }), + } as Response; + } + if (url.includes("/v1/providers")) { + const providers: any[] = [ + { + name: "openai", title: "OpenAI", needs_key: true, configured: false, values: {}, suggested_models: [], + recommended_model: "gpt-5.6-sol", + fields: [{ key: "api_key", label: "OpenAI API key", secret: true, required: true }], + }, + ]; + if (created) { + providers.push({ + name: "acme-gateway", title: "Acme Gateway", needs_key: true, is_custom: true, configured: true, + values: { base_url: "https://llm.acme.example/v1" }, suggested_models: [], recommended_model: null, + fields: [ + { key: "api_key", label: "Acme Gateway API key", secret: true, required: true }, + { key: "base_url", label: "Endpoint", secret: false, required: true, placeholder: "https://…/v1" }, + ], + }); + } + return { json: async () => providers } as Response; + } + if (url.includes("/v1/settings")) { + return { json: async () => ({ ok: true, model: "gpt-5.6-sol", models: ["gpt-5.6-sol"], model_labels: {}, source: null }) } as Response; + } + return { json: async () => ({ ok: true }) } as Response; + }), + ); + return calls; +} + +it("creates a named provider from the add box and opens its card", async () => { + const calls = stubApi(); + render(); + fireEvent.click(await screen.findByTestId("set-provider-custom-add")); + fireEvent.change(screen.getByTestId("custom-provider-title"), { target: { value: "Acme Gateway" } }); + // The prefix follows the name until the user edits it. + expect((screen.getByTestId("custom-provider-route") as HTMLInputElement).value).toBe("acme-gateway"); + fireEvent.change(screen.getByTestId("custom-provider-endpoint"), { target: { value: "https://llm.acme.example/v1" } }); + fireEvent.change(screen.getByTestId("custom-provider-key"), { target: { value: "acme-test" } }); + fireEvent.click(screen.getByTestId("custom-provider-add")); + + await waitFor(() => { + expect(calls.find((c) => c.url.includes("/v1/providers/custom"))).toMatchObject({ + method: "POST", + body: { name: "acme-gateway", title: "Acme Gateway", fields: { base_url: "https://llm.acme.example/v1", api_key: "acme-test" } }, + }); + }); + // The new card opens: its endpoint is shown (required, so never behind the disclosure). + expect(((await screen.findByTestId("set-field-base_url")) as HTMLInputElement).value).toBe("https://llm.acme.example/v1"); + expect(screen.getByTestId("set-remove-key").textContent).toContain("Remove provider"); + + // Fetch from server lists the ids as rows to tick. + fireEvent.click(screen.getByTestId("custom-fetch-models")); + await waitFor(() => expect(screen.getByTestId("custom-fetch-note").textContent).toContain("2 models listed")); + expect(screen.getByTitle("acme-gateway:acme-large")).toBeTruthy(); + expect(screen.getByTitle("acme-gateway:acme-small")).toBeTruthy(); +}); diff --git a/surfaces/gui/src/components/ManageTabs.tsx b/surfaces/gui/src/components/ManageTabs.tsx index a666875b..df61fd4a 100644 --- a/surfaces/gui/src/components/ManageTabs.tsx +++ b/surfaces/gui/src/components/ManageTabs.tsx @@ -6,6 +6,8 @@ import { connectConnector, connectManaged, connectMcpBacked, + createCustomProvider, + getLocalModels, disallowUser, getSettings, getSubscriptions, @@ -47,6 +49,7 @@ const relTime = (epoch?: number | null): string | null => { // when Settings/Activity became full-page surfaces): ModelsTab → Settings ▸ Models; ConnectorsTab → // Integrations ▸ Connectors (the MCP tab retired into the Connectors page, UX-034). const SEC_H = "text-label text-faint font-medium"; +const CARD = "rounded-xl2 border border-line bg-panel"; const BTN_BORDERED = "text-ui px-3 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; const BTN_ACCENT = "text-ui px-3 py-1.5 rounded-lg bg-accent text-white shrink-0 disabled:opacity-50"; @@ -78,10 +81,11 @@ export function ProviderKeyFooter({ className="text-ui text-danger/80 hover:text-danger hover:underline underline-offset-2" data-testid="set-remove-key" onClick={() => { - if (window.confirm(t("manage.remove_key_confirm", { title: info?.title || "" }))) onRemove(); + const key = info?.is_custom ? "manage.remove_provider_confirm" : "manage.remove_key_confirm"; + if (window.confirm(t(key, { title: info?.title || "" }))) onRemove(); }} > - {t("manage.remove_key")} + {t(info?.is_custom ? "manage.remove_provider" : "manage.remove_key")} ); } @@ -97,6 +101,13 @@ export function ModelsTab() { // The Pick a model dialog (UX-055): "" closed, "+" picking, else the model to configure. const [dialog, setDialog] = useState(""); const [settingsError, setSettingsError] = useState(false); + const [addingCustom, setAddingCustom] = useState(false); + const [openCustom, setOpenCustom] = useState(null); + // A custom provider's "Fetch from server": the ids its GET /models listed (offered as + // suggestions in the checklist) and the one-line outcome. + const [fetched, setFetched] = useState([]); + const [fetching, setFetching] = useState(false); + const [fetchNote, setFetchNote] = useState(null); const refreshSettings = () => { setSettingsError(false); getSettings() @@ -118,6 +129,33 @@ export function ModelsTab() { } catch { /* private window */ } }, []); + useEffect(() => { + if (openCustom && ps.providers.some((p) => p.name === openCustom)) { + setOpenCustom(null); + ps.openProvider(openCustom); + } + }, [openCustom, ps.providers]); + + useEffect(() => { + setFetched([]); + setFetchNote(null); + }, [ps.sel]); + + const fetchModels = async () => { + if (!ps.sel) return; + setFetching(true); + setFetchNote(null); + try { + const res = await getLocalModels(ps.sel); + const ids = res.models.map((m) => m.name); + setFetched(ids); + setFetchNote(res.error || (ids.length ? t("manage.custom_fetched_count", { count: ids.length }) : t("manage.custom_fetched_none"))); + } catch { + setFetchNote(t("manage.local_models_failed")); + } + setFetching(false); + }; + // #43: a failed or hung settings fetch says so and offers Retry (same shape as the // local-models status line below) instead of sitting on "Loading…" forever. if (settingsError) { @@ -152,7 +190,19 @@ export function ModelsTab() { if (ps.sel === null) { return (
- + {addingCustom ? ( + setAddingCustom(false)} + onCreated={async (name) => { + setAddingCustom(false); + setOpenCustom(name); + await ps.refreshProviders(); + refreshSettings(); + }} + /> + ) : ( + setAddingCustom(true)} /> + )} setDialog("+")} onConfigure={(m) => setDialog(m)} /> {dialogEl}
@@ -195,7 +245,7 @@ export function ModelsTab() {
{t("manage.models")}

- {t("manage.models_help")} + {t(info?.is_custom ? "manage.custom_models_help" : "manage.models_help")}

{ps.sel === "azure" && ( // No catalogue on Azure: the id to add is the deployment's name. @@ -203,11 +253,18 @@ export function ModelsTab() { {t("manage.azure_deployment_help")}

)} + {info?.is_custom && ( +
+ + {fetchNote && {fetchNote}} +
+ )} a.indexOf(m) === i)} curated={settings.models} defaultModel={settings.model} labels={settings.model_labels} @@ -244,6 +301,125 @@ export function ModelsTab() { ); } +/** A route prefix from a display name: lowercase, dashes between words, starting with a + * letter, at most 32 characters — the slug the backend accepts for `:`. */ +export function routePrefixFor(title: string): string { + return title + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/^[^a-z]+/, "") + .slice(0, 32) + .replace(/-+$/, ""); +} + +/** The form behind the "Add an OpenAI-compatible provider" box: a name of the user's + * choosing (the new card's title), the route prefix derived from it (editable until the + * first save), the endpoint and the key. Saving creates the card and opens it, where the + * model ids are typed or fetched from the server. */ +function CustomProviderForm({ + onCancel, + onCreated, +}: { + onCancel: () => void; + onCreated: (name: string) => Promise; +}) { + const { t } = useTranslation(); + const [title, setTitle] = useState(""); + const [name, setName] = useState(""); + const [nameTouched, setNameTouched] = useState(false); + const [baseUrl, setBaseUrl] = useState(""); + const [apiKey, setApiKey] = useState(""); + const [error, setError] = useState(null); + const [saving, setSaving] = useState(false); + const input = "w-full px-3 py-2 rounded-lg border border-line bg-panel text-ui outline-none focus:border-accent"; + const label = "block text-meta text-muted mt-3 mb-1"; + const prefix = name || routePrefixFor(title); + + const save = async () => { + setSaving(true); + setError(null); + const result: { ok: boolean; error?: string; provider?: string } = await createCustomProvider( + prefix, + title, + { base_url: baseUrl, api_key: apiKey }, + ).catch(() => ({ ok: false, error: t("provider.err_unreachable") })); + setSaving(false); + if (!result.ok || !result.provider) { + setError(result.error || t("manage.custom_add_failed")); + return; + } + await onCreated(result.provider); + }; + + return ( +
+
{t("manage.custom_add_title")}
+

{t("manage.custom_add_desc")}

+ + { + setTitle(e.target.value); + if (!nameTouched) setName(routePrefixFor(e.target.value)); + }} + /> + + { + setNameTouched(true); + setName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, "")); + }} + /> +

{t("manage.custom_route_hint", { prefix: prefix || "acme" })}

+ + setBaseUrl(e.target.value)} + /> +

{t("manage.custom_endpoint_help")}

+ + setApiKey(e.target.value)} + /> +
{error && {error}}
+
+ + +
+
+ ); +} + // The gallery view's "In the composer's picker" card: every curated model across providers, // with its provider tag. Unticking removes it from the picker; adding happens from a // provider's card (the ModelChecklist there has the suggested list + free-type add). diff --git a/surfaces/gui/src/components/models/ProviderGroups.tsx b/surfaces/gui/src/components/models/ProviderGroups.tsx index cba51e8b..819fe011 100644 --- a/surfaces/gui/src/components/models/ProviderGroups.tsx +++ b/surfaces/gui/src/components/models/ProviderGroups.tsx @@ -39,10 +39,12 @@ export function ProviderGroups({ ps, tp, models, + onAddCustom, }: { ps: ProviderSetupState; tp: string; models: string[]; // the picker's models, for the count on each card + onAddCustom?: () => void; // Settings: the permanent "Add an OpenAI-compatible provider" box, last in the API-keys group }) { const { t } = useTranslation(); const [query, setQuery] = useState(""); @@ -110,6 +112,21 @@ export function ProviderGroups({ {/* A long group (API keys) shows two rows, then a "Show N more" line expands it in place. */}
{(expanded.has(kind) || q ? cards : cards.slice(0, FOLD)).map(cardFor)} + {/* The add box stays whatever the fold or the search shows: saving it makes a + new card named by the user, and the box remains for the next one. */} + {kind === "api_key" && onAddCustom && ( + + )}
{!q && cards.length > FOLD && ( ))} + {onAddCustom && ( + + )}
); } @@ -703,6 +733,7 @@ export function ProviderForm({ {/* Error line: fixed height so failures never reflow the form. */}
{ps.verify.state === "error" && {ps.verify.msg}} + {ps.verify.state === "ok" && ps.verify.msg && {ps.verify.msg}}
{info?.auth === "oauth" ? null : footer} diff --git a/surfaces/gui/src/providers/logos.ts b/surfaces/gui/src/providers/logos.ts index ddb0cd95..db073fe1 100644 --- a/surfaces/gui/src/providers/logos.ts +++ b/surfaces/gui/src/providers/logos.ts @@ -94,6 +94,11 @@ export const PROVIDER_ORDER = [ "llamacpp", "vllm", "lmstudio", + // Jan, Docker Model Runner and mlx-lm carry no mark yet: the gallery shows the + // neutral monogram (see ProviderMark). + "jan", + "docker", + "mlx", "bedrock", "vertex", "openrouter", @@ -115,6 +120,11 @@ export const PROVIDER_ORDER = [ "novita", "groq", "upstage", + // No vendored marks yet for these four: the gallery shows the neutral monogram. + "cerebras", + "huggingface", + "cohere", + "siliconflow", ]; export function providerRank(name: string): number { diff --git a/tests/test_local_server.py b/tests/test_local_server.py index 889809ee..6bf632b9 100644 --- a/tests/test_local_server.py +++ b/tests/test_local_server.py @@ -1,5 +1,5 @@ -"""llama.cpp, vLLM and LM Studio as providers (providers/local_server.py, registry.py): the user's -own server through its /v1, what each reports about its model, and how the server's +"""The OpenAI-compatible local servers as providers (providers/local_server.py, registry.py: +llama.cpp, vLLM, LM Studio, Jan, Docker Model Runner, mlx-lm): the user's own server through its /v1, what each reports about its model, and how the server's model lists, liveness and adoption treat them.""" from __future__ import annotations @@ -30,9 +30,10 @@ def _fake_http(monkeypatch, handler): def test_descriptors_carry_a_kind_for_grouping(): kinds = {d.name: d.kind for d in provider_descriptors()} assert kinds["ollama"] == kinds["llamacpp"] == kinds["vllm"] == kinds["lmstudio"] == "local" + assert kinds["jan"] == kinds["docker"] == kinds["mlx"] == "local" assert kinds["openai-codex"] == "subscription" assert kinds["openai"] == kinds["anthropic"] == "api_key" - for name in ("llamacpp", "vllm", "lmstudio"): + for name in local_server.LOCAL_SERVERS: d = get_descriptor(name) assert d is not None and not d.needs_key assert [f.key for f in d.fields] == ["base_url", "api_key"] @@ -46,6 +47,16 @@ def test_v1_base_takes_the_root_or_an_explicit_v1(): assert local_server.v1_base("vllm", "http://box.local:8000/v1") == "http://box.local:8000/v1" assert local_server.v1_base("lmstudio", None) == "http://localhost:1234/v1" assert local_server.v1_base("lmstudio", "http://127.0.0.1:1234/v1/") == "http://127.0.0.1:1234/v1" + assert local_server.v1_base("jan", None) == "http://127.0.0.1:1337/v1" + assert local_server.v1_base("mlx", None) == "http://127.0.0.1:8080/v1" + assert local_server.v1_base("mlx", "http://127.0.0.1:8081") == "http://127.0.0.1:8081/v1" + # Docker Model Runner's API sits under /engines: the root, /engines and /engines/v1 all + # land on the same place, and a named engine path is kept. + assert local_server.v1_base("docker", None) == "http://localhost:12434/engines/v1" + assert local_server.v1_base("docker", "http://localhost:12434") == "http://localhost:12434/engines/v1" + assert local_server.v1_base("docker", "http://localhost:12434/engines") == "http://localhost:12434/engines/v1" + assert local_server.v1_base("docker", "http://localhost:12434/engines/v1") == "http://localhost:12434/engines/v1" + assert local_server.v1_base("docker", "http://box:12434/engines/llama.cpp/") == "http://box:12434/engines/llama.cpp/v1" def test_the_builder_sends_a_placeholder_key_when_none_is_set(): @@ -56,6 +67,10 @@ def test_the_builder_sends_a_placeholder_key_when_none_is_set(): assert client._base_url == "http://gpu:8000/v1" and client._api_key == "s3" client = get_descriptor("lmstudio").build({}, None) assert client._base_url == "http://localhost:1234/v1" and client._api_key == "none" + client = get_descriptor("docker").build({"base_url": "http://localhost:12434"}, None) + assert client._base_url == "http://localhost:12434/engines/v1" and client._api_key == "none" + client = get_descriptor("jan").build({}, None) + assert client._base_url == "http://127.0.0.1:1337/v1" def test_llamacpp_facts_come_from_models_and_props(monkeypatch): @@ -124,6 +139,29 @@ def test_lmstudio_facts_survive_a_missing_own_listing(monkeypatch): assert row["context"] is None and row["context_max"] is None +@pytest.mark.parametrize("name,base,path", [ + ("jan", "http://127.0.0.1:1337", "/v1/models"), + ("docker", "http://localhost:12434", "/engines/v1/models"), + ("mlx", "http://127.0.0.1:8081", "/v1/models"), +]) +def test_plain_listing_servers_report_their_models(monkeypatch, name, base, path): + """Jan, Docker Model Runner and mlx-lm answer a plain OpenAI listing: one row per id, + routed under the provider's prefix, with no window claimed.""" + seen = [] + + def handler(request: httpx.Request) -> httpx.Response: + seen.append(request.url.path) + if request.url.path == path: + return httpx.Response(200, json={"data": [{"id": "org/some-model", "object": "model"}]}) + return httpx.Response(404) + + _fake_http(monkeypatch, handler) + (row,) = local_server.model_facts(name, base) + assert row["model"] == f"{name}:org/some-model" and row["name"] == "org/some-model" + assert row["context"] is None and row["tools"] is None + assert seen[0] == path + + def test_vllm_facts_probe_whether_tool_calling_is_on(monkeypatch): calls = [] @@ -166,7 +204,7 @@ def test_thinking_switch_reaches_llama_cpp_and_vllm_as_template_kwargs(monkeypat sent = {} monkeypatch.setattr(openai_provider.OpenAIProvider, "complete", lambda self, **kw: sent.update(kw) or "ok") - for name in ("llamacpp", "vllm", "lmstudio"): + for name in local_server.LOCAL_SERVERS: client = get_descriptor(name).build({}, None) client.complete(model="m", messages=[], max_tokens=10, extra_body={"think": False}) assert sent["extra_body"] == {"chat_template_kwargs": {"enable_thinking": False}} diff --git a/tests/test_provider_router.py b/tests/test_provider_router.py index d399566d..bb3a5d14 100644 --- a/tests/test_provider_router.py +++ b/tests/test_provider_router.py @@ -5,6 +5,8 @@ from __future__ import annotations from types import SimpleNamespace +import pytest + from coworker.providers import ( AssistantTurn, ModelCapabilities, @@ -391,6 +393,166 @@ def test_manager_provider_config(tmp_path, monkeypatch): assert mgr.set_provider("nope", {})["ok"] is False # unknown provider rejected +def test_custom_openai_compatible_provider_routes_and_persists(tmp_path, monkeypatch): + """A custom route owns its key/endpoint and strips only its own prefix. The slug + must not be a built-in provider's name (openrouter is one now), so a neutral one.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + created = mgr.create_custom_provider( + "acme", + "Acme Gateway", + {"api_key": "acme-test", "base_url": "https://llm.acme.example/v1"}, + ) + assert created["ok"] is True and created["provider"] == "acme" + + providers = {p["name"]: p for p in mgr.get_providers()} + assert providers["acme"]["is_custom"] is True + assert providers["acme"]["values"] == { + "base_url": "https://llm.acme.example/v1" + } + assert "acme-test" not in str(providers) + + router = mgr.provider + assert router._provider_name("acme:anthropic/claude-example") == "acme" + assert ( + router._bare("acme:anthropic/claude-example", mgr.secrets) + == "anthropic/claude-example" + ) + client = router._client_for("acme:anthropic/claude-example") + assert client._api_key == "acme-test" and client._base_url == "https://llm.acme.example/v1" + + mgr.add_model("acme:anthropic/claude-example") + assert "acme:anthropic/claude-example" in mgr.get_settings()["models"] + assert mgr.remove_provider("acme")["ok"] is True + assert "acme" not in {p["name"] for p in mgr.get_providers()} + assert "acme:anthropic/claude-example" not in mgr.get_settings()["models"] + # Existing sessions retain the full routed model id. After removal that must + # fail locally, never fall through to whichever provider is now the default. + assert router._provider_name("acme:anthropic/claude-example") == "acme" + with pytest.raises(RuntimeError, match="was removed"): + router.complete(model="acme:anthropic/claude-example", messages=[]) + + # Re-creating the same route intentionally clears its tombstone. + assert mgr.create_custom_provider( + "acme", + "Acme Gateway", + {"api_key": "acme-recreated", "base_url": "https://llm.acme.example/v1"}, + )["ok"] + assert router._client_for("acme:anthropic/claude-example")._api_key == "acme-recreated" + + +def test_custom_provider_requires_safe_route_and_complete_config(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + assert not mgr.create_custom_provider("Bad Name", "Gateway", {})["ok"] + assert not mgr.create_custom_provider( + "gateway", "Gateway", {"api_key": "x", "base_url": "gateway.example/v1"} + )["ok"] + assert not mgr.create_custom_provider( + "remote-http", "Remote HTTP", {"api_key": "x", "base_url": "http://example.com/v1"} + )["ok"] + assert mgr.create_custom_provider( + "local-http", "Local HTTP", {"api_key": "x", "base_url": "http://127.0.0.1:8000/v1"} + )["ok"] + + +def test_custom_provider_slug_cannot_shadow_a_built_in(tmp_path, monkeypatch): + """A custom route prefix is the model id's namespace; a built-in's name is taken.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + res = mgr.create_custom_provider( + "groq", "My Groq", {"api_key": "x", "base_url": "https://api.groq.com/openai/v1"} + ) + assert res["ok"] is False and "already in use" in res["error"] + assert all(not p.get("is_custom") for p in mgr.get_providers() if p["name"] == "groq") + + +def test_custom_provider_key_change_reaches_the_router(tmp_path, monkeypatch): + """Re-saving a custom provider's key rebuilds its client (the adopt step used to look + the descriptor up without the store and skip the refresh for custom routes).""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + assert mgr.create_custom_provider( + "acme", "Acme Gateway", {"api_key": "acme-1", "base_url": "https://llm.acme.example/v1"} + )["ok"] + assert mgr.provider._client_for("acme:some-model")._api_key == "acme-1" + assert mgr.set_provider("acme", {"api_key": "acme-2"})["ok"] + assert mgr.provider._client_for("acme:some-model")._api_key == "acme-2" + + +def test_custom_provider_lists_models_from_its_server(tmp_path, monkeypatch): + """"Fetch from server" reads GET /models with the saved key; a server without the + listing comes back as a note, not a failure.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + assert mgr.create_custom_provider( + "acme", "Acme Gateway", {"api_key": "acme-test", "base_url": "https://llm.acme.example/v1"} + )["ok"] + seen = {} + + def fake_get(url, headers=None, timeout=None): + seen.update(url=url, headers=headers or {}) + return SimpleNamespace(status_code=200, json=lambda: {"data": [{"id": "acme-large"}, {"id": "acme-small"}]}) + + monkeypatch.setattr("httpx.get", fake_get) + got = mgr.local_model_facts("acme") + assert [r["model"] for r in got["models"]] == ["acme:acme-large", "acme:acme-small"] + assert got["models"][0]["name"] == "acme-large" and "error" not in got + assert seen["url"] == "https://llm.acme.example/v1/models" + assert seen["headers"]["Authorization"] == "Bearer acme-test" + + monkeypatch.setattr("httpx.get", lambda *a, **k: SimpleNamespace(status_code=404, json=lambda: {})) + got = mgr.local_model_facts("acme") + assert got["models"] == [] and "/models" in got["error"] + + +def test_custom_provider_verify_allows_missing_models_endpoint(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + assert mgr.create_custom_provider( + "gateway", "Gateway", {"api_key": "test", "base_url": "https://gateway.example/v1"} + )["ok"] + + monkeypatch.setattr( + "httpx.get", lambda *args, **kwargs: SimpleNamespace(status_code=404) + ) + checked = mgr.verify_provider("gateway", {}) + assert checked["ok"] is True and "does not expose /models" in checked["warning"] + + +def test_custom_provider_verify_applies_endpoint_transport_policy(tmp_path, monkeypatch): + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + from coworker.server.manager import SessionManager + + mgr = SessionManager(data_dir=tmp_path) + assert mgr.create_custom_provider( + "gateway", "Gateway", {"api_key": "test", "base_url": "https://gateway.example/v1"} + )["ok"] + + # Verify accepts unsaved form fields, so it must independently reject an + # unsafe replacement URL before it makes any request with the key. + monkeypatch.setattr( + "httpx.get", lambda *args, **kwargs: pytest.fail("unsafe endpoint was requested") + ) + checked = mgr.verify_provider( + "gateway", {"base_url": "http://example.com/v1", "api_key": "test"} + ) + assert checked["ok"] is False + assert "localhost or a private IP" in checked["error"] + + def test_manager_curated_models(tmp_path, monkeypatch): """No seed list: the picker is the curated matrix filtered to key-holding providers, plus user-added custom ids. A fresh install shows only the (not-yet-usable) default. diff --git a/tests/test_providers.py b/tests/test_providers.py index d7e87b4a..b4a229a9 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -295,6 +295,10 @@ COMPAT_VENDORS = { "nvidia": "https://integrate.api.nvidia.com/v1", "atlascloud": "https://api.atlascloud.ai/v1", "upstage": "https://api.upstage.ai/v1", + "cerebras": "https://api.cerebras.ai/v1", + "huggingface": "https://router.huggingface.co/v1", + "cohere": "https://api.cohere.ai/compatibility/v1", + "siliconflow": "https://api.siliconflow.com/v1", } @@ -477,6 +481,10 @@ def test_compat_models_route_and_get_tool_capabilities(): "orcarouter:anthropic/claude-sonnet-5", "atlascloud:zai-org/glm-5.2", "upstage:solar-pro4", + "cerebras:gpt-oss-120b", + "huggingface:zai-org/GLM-5.2", + "cohere:command-a-plus-05-2026", + "siliconflow:zai-org/GLM-5.2", ): prefix = model.split(":", 1)[0] assert router._provider_name(model) == prefix diff --git a/tests/test_server.py b/tests/test_server.py index 75b50807..1786ce8c 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -1125,6 +1125,32 @@ def test_provider_set_and_remove_roundtrip(tmp_path): assert not client.delete("/v1/providers/nope").json()["ok"] +def test_custom_provider_rest_create_and_delete(tmp_path, monkeypatch): + """The public Settings API creates a separately-routed custom provider.""" + monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state")) + manager = SessionManager(workspace=tmp_path, provider=ScriptedProvider([])) + client = TestClient(create_app(manager)) + + created = client.post( + "/v1/providers/custom", + json={ + "name": "acme", + "title": "Acme Gateway", + "fields": { + "api_key": "acme-test", + "base_url": "https://llm.acme.example/v1", + }, + }, + ).json() + assert created == {"ok": True, "provider": "acme", "recommended_model": None} + provider = {p["name"]: p for p in client.get("/v1/providers").json()}["acme"] + assert provider["is_custom"] is True and provider["values"]["base_url"].endswith("/v1") + assert "acme-test" not in str(provider) + + assert client.delete("/v1/providers/acme").json()["ok"] is True + assert "acme" not in {p["name"] for p in client.get("/v1/providers").json()} + + def test_always_allow_grants_survive_restart(tmp_path): """"Always allow" is session-scoped, and the session outlives the process — a restart (fresh manager over the same store) must not re-ask for an approved command