Merge remote-tracking branch 'origin/main' into feat/azure-openai-card

# Conflicts:
#	README.md
#	surfaces/gui/src/components/ManageTabs.tsx
This commit is contained in:
Rohit C Prasad
2026-10-26 12:01:37 -07:00
32 changed files with 1385 additions and 65 deletions
+2 -1
View File
@@ -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 `<prefix>:<model-id>` (the prefix is derived from the name). Type the model ids, or fetch them from the server when it lists them.
## Privacy
+4
View File
@@ -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",
+3 -1
View File
@@ -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(
+24 -8
View File
@@ -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/<engine>/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
+15 -1
View File
@@ -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
+240 -10
View File
@@ -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 <repo>`).",
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."}
+5 -5
View File
@@ -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):
+7
View File
@@ -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":
+184 -14
View File
@@ -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:<name>` 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 <date>" 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:<name>` 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 `<name>:<id>`; empty when
"""The model ids a local server (local_server.LOCAL_SERVERS) serves, as `<name>:<id>`; 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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

+47 -4
View File
@@ -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/<repo>:<quant>
```
- 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/<repo>
```
- 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:<id>`, `vllm:<id>` or `lmstudio:<id>`:
The model id is `<provider>:<id>` — `llamacpp:<id>`, `vllm:<id>`, `lmstudio:<id>`,
`jan:<id>`, `docker:<id>` or `mlx:<id>`:
```bash
openworker run --model llamacpp:my-model --prompt "..."
+68
View File
@@ -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");
});
+90
View File
@@ -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<void>((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`) });
});
}
+35
View File
@@ -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 });
@@ -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)] }));
+16 -1
View File
@@ -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<string, string>,
): 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<string, string>,
): 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" },
@@ -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(<ModelsTab />);
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();
});
+182 -6
View File
@@ -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")}
</button>
);
}
@@ -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<string>("");
const [settingsError, setSettingsError] = useState(false);
const [addingCustom, setAddingCustom] = useState(false);
const [openCustom, setOpenCustom] = useState<string | null>(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<string[]>([]);
const [fetching, setFetching] = useState(false);
const [fetchNote, setFetchNote] = useState<string | null>(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 (
<div>
<ProviderGroups ps={ps} tp="set" models={settings.models} />
{addingCustom ? (
<CustomProviderForm
onCancel={() => setAddingCustom(false)}
onCreated={async (name) => {
setAddingCustom(false);
setOpenCustom(name);
await ps.refreshProviders();
refreshSettings();
}}
/>
) : (
<ProviderGroups ps={ps} tp="set" models={settings.models} onAddCustom={() => setAddingCustom(true)} />
)}
<ComposerPickerCard settings={settings} providers={ps.providers} onChanged={refreshSettings} onPick={() => setDialog("+")} onConfigure={(m) => setDialog(m)} />
{dialogEl}
</div>
@@ -195,7 +245,7 @@ export function ModelsTab() {
<div className="mt-6">
<div className={SEC_H + " mb-1.5"}>{t("manage.models")}</div>
<p className="text-meta text-muted mb-2.5 leading-relaxed">
{t("manage.models_help")}
{t(info?.is_custom ? "manage.custom_models_help" : "manage.models_help")}
</p>
{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")}
</p>
)}
{info?.is_custom && (
<div className="flex items-center gap-3 mb-2.5" data-testid="custom-fetch-row">
<button className={BTN_BORDERED} data-testid="custom-fetch-models" disabled={fetching} onClick={() => void fetchModels()}>
{fetching ? t("manage.custom_fetching") : t("manage.custom_fetch_models")}
</button>
{fetchNote && <span className="text-meta text-muted" data-testid="custom-fetch-note">{fetchNote}</span>}
</div>
)}
<ModelChecklist
provider={ps.sel}
knownProviders={knownNames}
suggested={info?.suggested_models || []}
suggested={[...(info?.suggested_models || []), ...fetched].filter((m, i, a) => 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 `<prefix>:<model-id>`. */
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<void>;
}) {
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<string | null>(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 (
<div className={CARD + " p-4 max-w-xl mb-6"} data-testid="set-custom-provider-form">
<div className="text-body font-semibold">{t("manage.custom_add_title")}</div>
<p className="text-meta text-muted mt-1 leading-relaxed">{t("manage.custom_add_desc")}</p>
<label className={label} htmlFor="custom-provider-title">{t("manage.custom_name_label")}</label>
<input
id="custom-provider-title"
className={input}
value={title}
placeholder={t("manage.custom_name_placeholder")}
data-testid="custom-provider-title"
onChange={(e) => {
setTitle(e.target.value);
if (!nameTouched) setName(routePrefixFor(e.target.value));
}}
/>
<label className={label} htmlFor="custom-provider-route">{t("manage.custom_route_label")}</label>
<input
id="custom-provider-route"
className={input + " font-mono"}
value={prefix}
spellCheck={false}
autoCapitalize="none"
data-testid="custom-provider-route"
onChange={(e) => {
setNameTouched(true);
setName(e.target.value.toLowerCase().replace(/[^a-z0-9_-]/g, ""));
}}
/>
<p className="text-meta text-faint mt-1">{t("manage.custom_route_hint", { prefix: prefix || "acme" })}</p>
<label className={label} htmlFor="custom-provider-endpoint">{t("manage.custom_endpoint_label")}</label>
<input
id="custom-provider-endpoint"
className={input}
value={baseUrl}
placeholder="https://…/v1"
spellCheck={false}
autoCapitalize="none"
data-testid="custom-provider-endpoint"
onChange={(e) => setBaseUrl(e.target.value)}
/>
<p className="text-meta text-faint mt-1">{t("manage.custom_endpoint_help")}</p>
<label className={label} htmlFor="custom-provider-key">{t("manage.custom_key_label")}</label>
<input
id="custom-provider-key"
className={input}
type="password"
value={apiKey}
autoComplete="off"
data-testid="custom-provider-key"
onChange={(e) => setApiKey(e.target.value)}
/>
<div className="mt-3 min-h-[19px] text-ui">{error && <span className="text-warnInk" data-testid="custom-provider-error">{error}</span>}</div>
<div className="flex gap-2 mt-1">
<button
className={BTN_ACCENT}
disabled={saving || !title.trim() || !prefix || !baseUrl.trim() || !apiKey.trim()}
data-testid="custom-provider-add"
onClick={() => void save()}
>
{saving ? t("manage.custom_adding") : t("manage.custom_add_btn")}
</button>
<button className={BTN_BORDERED} onClick={onCancel} data-testid="custom-provider-cancel">{t("manage.custom_cancel")}</button>
</div>
</div>
);
}
// 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).
@@ -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. */}
<div className="grid grid-cols-2 xl:grid-cols-3 gap-3" data-testid={`provider-grid-${kind}`}>
{(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 && (
<button
className={card + " border-dashed bg-transparent text-muted hover:text-ink"}
data-testid={`${tp}-provider-custom-add`}
onClick={onAddCustom}
>
<span className="rounded-lg border border-line grid place-items-center shrink-0 text-[20px]" style={{ width: 32, height: 32 }}>
+
</span>
<span className="min-w-0 flex-1 text-ui font-semibold">{t("manage.custom_provider_card")}</span>
<span className="text-faint text-body">›</span>
</button>
)}
</div>
{!q && cards.length > FOLD && (
<button
+28 -1
View File
@@ -1349,6 +1349,27 @@
"remove_from_picker": "Remove from the picker",
"remove_key": "Remove key…",
"remove_key_confirm": "Remove the {{title}} key from this computer?",
"remove_provider": "Remove provider…",
"remove_provider_confirm": "Remove the {{title}} provider and its key from this computer?",
"custom_provider_card": "Add an OpenAI-compatible provider",
"custom_add_title": "Add an OpenAI-compatible provider",
"custom_add_desc": "Any server that speaks the OpenAI chat API with a Bearer key — a gateway, a proxy, a vendor not listed here. HTTPS, except on localhost or a private address. You add its model ids next.",
"custom_name_label": "Name",
"custom_name_placeholder": "Acme Gateway",
"custom_route_label": "Route prefix",
"custom_route_hint": "Models are selected as {{prefix}}:<model-id>. Set it before the first save; a built-in provider's name cannot be used.",
"custom_endpoint_label": "Endpoint",
"custom_endpoint_help": "The base URL, usually ending in /v1.",
"custom_key_label": "API key",
"custom_add_btn": "Add provider",
"custom_adding": "Adding…",
"custom_cancel": "Cancel",
"custom_add_failed": "Couldn't add the provider.",
"custom_models_help": "Type the model ids your server accepts, or fetch its list. Ticked models show in the composer's picker.",
"custom_fetch_models": "Fetch from server",
"custom_fetching": "Fetching…",
"custom_fetched_count": "{{count}} models listed — tick the ones you want.",
"custom_fetched_none": "The server listed no models.",
"signin_unlocks": "Sign-in unlocks the one-click {{title}} connect — or connect manually below.",
"tool_asks_approval": "{{name}} · {{kind}} · asks approval",
"tools_exposed": "Tools exposed to OpenWorker",
@@ -1625,7 +1646,13 @@
"vllm_desc": "vLLM runs on Linux with an NVIDIA GPU. A key is needed only if the server was started with one.",
"install_vllm": "Install vLLM",
"lmstudio_desc": "The LM Studio app serves the models you downloaded there. A key is needed only if you turned one on in the app.",
"install_lmstudio": "Install LM Studio"
"install_lmstudio": "Install LM Studio",
"jan_desc": "The Jan app serves the models you downloaded there once its local API server is on (Settings ▸ Local API Server ▸ Start Server). A key is needed only if you set one there.",
"install_jan": "Set up Jan's API server",
"docker_desc": "Docker Desktop serves models you pulled with `docker model pull hf.co/<repo>:<quant>`. Turn on Model Runner and its TCP access in Docker Desktop ▸ Settings ▸ AI. No key needed.",
"install_docker": "Set up Docker Model Runner",
"mlx_desc": "mlx-lm runs on Apple silicon: `pip install mlx-lm`, then `mlx_lm.server --model <repo>`. It uses port 8080 like llama.cpp — if you run both, start one on another port, e.g. `mlx_lm.server --port 8081`.",
"install_mlx": "Set up mlx-lm"
},
"workspace_trust": {
"title": "Trust this workspace’s commands?",
+28 -1
View File
@@ -1332,6 +1332,27 @@
"remove_from_picker": "从选择器中移除",
"remove_key": "移除密钥…",
"remove_key_confirm": "从本机移除 {{title}} 密钥?",
"remove_provider": "移除提供商…",
"remove_provider_confirm": "从本机移除 {{title}} 提供商及其密钥?",
"custom_provider_card": "添加 OpenAI 兼容提供商",
"custom_add_title": "添加 OpenAI 兼容提供商",
"custom_add_desc": "任何使用 Bearer 密钥、支持 OpenAI 聊天 API 的服务器——网关、代理或未列出的供应商。需使用 HTTPS,localhost 或内网地址除外。下一步添加其模型 ID。",
"custom_name_label": "名称",
"custom_name_placeholder": "Acme Gateway",
"custom_route_label": "路由前缀",
"custom_route_hint": "模型将以 {{prefix}}:<model-id> 的形式选择。请在首次保存前设定;不能使用内置提供商的名称。",
"custom_endpoint_label": "端点",
"custom_endpoint_help": "基础 URL,通常以 /v1 结尾。",
"custom_key_label": "API 密钥",
"custom_add_btn": "添加提供商",
"custom_adding": "添加中…",
"custom_cancel": "取消",
"custom_add_failed": "无法添加该提供商。",
"custom_models_help": "输入服务器接受的模型 ID,或获取其列表。勾选的模型会出现在编辑器的选择器中。",
"custom_fetch_models": "从服务器获取",
"custom_fetching": "获取中…",
"custom_fetched_count": "列出了 {{count}} 个模型——勾选你需要的。",
"custom_fetched_none": "服务器未列出任何模型。",
"signin_unlocks": "登录即可解锁 {{title}} 一键连接 —— 或在下方手动连接。",
"tool_asks_approval": "{{name}} · {{kind}} · 需审批",
"tools_exposed": "向 OpenWorker 提供的工具",
@@ -1605,7 +1626,13 @@
"vllm_desc": "vLLM 运行在带 NVIDIA GPU 的 Linux 上。仅当服务器启动时设置了密钥才需要填写。",
"install_vllm": "安装 vLLM",
"lmstudio_desc": "LM Studio 应用会提供你在其中下载的模型。仅当你在应用中开启了密钥才需要填写。",
"install_lmstudio": "安装 LM Studio"
"install_lmstudio": "安装 LM Studio",
"jan_desc": "开启 Jan 的本地 API 服务器后(设置 ▸ Local API Server ▸ Start Server),Jan 应用会提供你在其中下载的模型。仅当你在那里设置了密钥才需要填写。",
"install_jan": "设置 Jan 的 API 服务器",
"docker_desc": "Docker Desktop 会提供你用 `docker model pull hf.co/<repo>:<quant>` 拉取的模型。请在 Docker Desktop ▸ Settings ▸ AI 中开启 Model Runner 及其 TCP 访问。无需密钥。",
"install_docker": "设置 Docker Model Runner",
"mlx_desc": "mlx-lm 运行在 Apple 芯片上:`pip install mlx-lm`,然后 `mlx_lm.server --model <repo>`。它与 llama.cpp 同样使用 8080 端口——如果两者都运行,请让其中一个使用其他端口,例如 `mlx_lm.server --port 8081`。",
"install_mlx": "设置 mlx-lm"
},
"workspace_trust": {
"title": "信任此工作区的命令?",
+39 -8
View File
@@ -29,6 +29,9 @@ const LOCAL_HELP: Record<string, { desc: string; link: string; url: string }> =
llamacpp: { desc: "provider.llamacpp_desc", link: "provider.install_llamacpp", url: "https://github.com/ggml-org/llama.cpp#quick-start" },
vllm: { desc: "provider.vllm_desc", link: "provider.install_vllm", url: "https://docs.vllm.ai/en/latest/getting_started/installation/" },
lmstudio: { desc: "provider.lmstudio_desc", link: "provider.install_lmstudio", url: "https://lmstudio.ai/download" },
jan: { desc: "provider.jan_desc", link: "provider.install_jan", url: "https://docs.jan.ai/desktop/api-server" },
docker: { desc: "provider.docker_desc", link: "provider.install_docker", url: "https://docs.docker.com/ai/model-runner/" },
mlx: { desc: "provider.mlx_desc", link: "provider.install_mlx", url: "https://github.com/ml-explore/mlx-lm/blob/main/mlx_lm/SERVER.md" },
};
export const KEY_HELP: Record<string, { url: string; label: string; labelKey?: string }> = {
@@ -59,6 +62,10 @@ export const KEY_HELP: Record<string, { url: string; label: string; labelKey?: s
atlascloud: { url: "https://www.atlascloud.ai/console/api-keys", label: "atlascloud.ai" },
groq: { url: "https://console.groq.com/keys", label: "console.groq.com" },
upstage: { url: "https://console.upstage.ai/api-keys", label: "console.upstage.ai" },
cerebras: { url: "https://cloud.cerebras.ai", label: "cloud.cerebras.ai" },
huggingface: { url: "https://huggingface.co/settings/tokens", label: "huggingface.co" },
cohere: { url: "https://dashboard.cohere.com/api-keys", label: "dashboard.cohere.com" },
siliconflow: { url: "https://cloud.siliconflow.com", label: "cloud.siliconflow.com" },
};
export type Verify = { state: "idle" | "testing" | "ok" | "error"; msg?: string };
@@ -165,7 +172,9 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
setFields(next);
setDirty(!!draft && Object.values(draft).some(Boolean));
setVerify({ state: "idle" });
setShowEndpoint(false);
// A custom provider cannot work without its endpoint, so keep that field
// visible instead of hiding it behind the built-ins' expert disclosure.
setShowEndpoint(!!p?.fields.find((f) => f.key === "base_url")?.required);
};
const backToGallery = () => {
@@ -183,14 +192,16 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
const runTestAndSave = async (): Promise<boolean> => {
if (!sel) return false;
setVerify({ state: "testing" });
const res = await verifyProvider(sel, fields).catch(() => ({ ok: false, error: t("provider.err_unreachable") }));
const res: { ok: boolean; error?: string; warning?: string } = await verifyProvider(sel, fields).catch(
() => ({ ok: false, error: t("provider.err_unreachable") }),
);
if (!res.ok) {
setVerify({ state: "error", msg: res.error || t("provider.err_couldnt_verify") });
return false;
}
if (dirty || !info?.configured) await setProvider(sel, fields).catch(() => {});
if (!info?.needs_key) setKeylessOk((s) => new Set(s).add(sel));
setVerify({ state: "ok" });
setVerify({ state: "ok", msg: res.warning });
setDirty(false);
setDrafts((d) => ({ ...d, [sel]: {} }));
await refreshProviders();
@@ -199,11 +210,15 @@ export function useProviderSetup(opts?: { onSaved?: () => void }): ProviderSetup
// the timeout would fire its stale closure (dirty/fields from before the save) and
// re-stash the just-saved key as a draft — the state-restore bug (owner catch
// 2026-07-19). This return path clears the draft unconditionally.
backTimer.current = window.setTimeout(() => {
setDrafts((d) => ({ ...d, [sel]: {} }));
setSel(null);
setVerify({ state: "idle" });
}, 900);
// A catalog-less gateway is still usable, but its warning contains the next action;
// leave the form open instead of making it disappear after the usual success animation.
if (!res.warning) {
backTimer.current = window.setTimeout(() => {
setDrafts((d) => ({ ...d, [sel]: {} }));
setSel(null);
setVerify({ state: "idle" });
}, 900);
}
return true;
};
@@ -416,11 +431,13 @@ export function ProviderCards({
tp,
gridClass = "grid grid-cols-2 gap-2.5",
lastUsed = false,
onAddCustom,
}: {
ps: ProviderSetupState;
tp: string; // testid prefix ("ob" onboarding, "set" settings)
gridClass?: string;
lastUsed?: boolean;
onAddCustom?: () => void;
}) {
const card =
"flex items-center gap-2.5 rounded-xl border border-line bg-panel px-3 py-2.5 text-left hover:border-lineStrong transition-colors";
@@ -441,6 +458,19 @@ export function ProviderCards({
<span className="text-faint text-body">›</span>
</button>
))}
{onAddCustom && (
<button
className={card + " border-dashed text-muted hover:text-ink"}
data-testid={`${tp}-provider-custom-add`}
onClick={onAddCustom}
>
<span className="rounded-lg border border-line grid place-items-center shrink-0 text-[20px]" style={{ width: 32, height: 32 }}>
+
</span>
<span className="min-w-0 flex-1 text-[13px] font-semibold">Custom provider</span>
<span className="text-faint text-[14px]">›</span>
</button>
)}
</div>
);
}
@@ -703,6 +733,7 @@ export function ProviderForm({
{/* Error line: fixed height so failures never reflow the form. */}
<div className="mt-3 min-h-[19px] text-ui">
{ps.verify.state === "error" && <span className="text-warnInk">{ps.verify.msg}</span>}
{ps.verify.state === "ok" && ps.verify.msg && <span className="text-muted">{ps.verify.msg}</span>}
</div>
{info?.auth === "oauth" ? null : footer}
</div>
+10
View File
@@ -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 {
+42 -4
View File
@@ -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}}
+162
View File
@@ -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.
+8
View File
@@ -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
+26
View File
@@ -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