mirror of
https://github.com/andrewyng/openworker.git
synced 2026-10-30 10:47:19 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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."}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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
@@ -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 "..."
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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`) });
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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": "信任此工作区的命令?",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user