Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 78 additions & 19 deletions coworker/providers/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,76 @@ def _verify_vertex(fields: dict[str, Any], timeout: float) -> dict[str, Any]:
return {"ok": False, "error": f"Vertex AI returned HTTP {resp.status_code}."}


def _verify_openai_compat(
d: ProviderDescriptor,
key: str,
base_url: Optional[str],
timeout: float,
) -> dict[str, Any]:
"""List models, then probe `/chat/completions` so a missing `/v1` fails Test (#431)."""
import httpx

default_base = next(
(f.default for f in d.fields if f.key == "base_url" and f.default), ""
)
base = (
(base_url or "").strip().rstrip("/")
or default_base.rstrip("/")
or "https://api.openai.com/v1"
)
headers = {"Authorization": f"Bearer {key}"}

def _unreachable(exc: Exception) -> dict[str, Any]:
return {
"ok": False,
"error": f"Couldn't reach {d.title} ({exc.__class__.__name__}).",
}

def _http_error(code: int) -> dict[str, Any]:
if code in (401, 403):
return {"ok": False, "error": "Invalid API key."}
return {"ok": False, "error": f"{d.title} returned HTTP {code}."}

try:
resp = httpx.get(base + "/models", headers=headers, timeout=timeout)
except Exception as exc:
return _unreachable(exc)
if resp.status_code >= 300:
return _http_error(resp.status_code)

try:
data = resp.json().get("data") or []
model = str(data[0]["id"]) if data and data[0].get("id") else "openworker-verify"
except Exception:
model = "openworker-verify"

try:
creq = httpx.post(
base + "/chat/completions",
headers=headers,
json={
"model": model,
"messages": [{"role": "user", "content": "ping"}],
"max_tokens": 1,
},
timeout=timeout,
)
except Exception as exc:
return _unreachable(exc)
if creq.status_code < 300 or creq.status_code in (400, 422):
return {"ok": True} # 400/422: path exists, model rejected
if creq.status_code == 404:
return {
"ok": False,
"error": (
"Reached the server, but chat completions aren't at this endpoint. "
"OpenAI-compatible URLs usually need a `/v1` suffix "
"(e.g. http://127.0.0.1:1234/v1)."
),
}
return _http_error(creq.status_code)


def verify_provider_key(
name: str,
*,
Expand All @@ -803,10 +873,10 @@ def verify_provider_key(
fields: Optional[dict[str, Any]] = None,
timeout: float = 10.0,
) -> dict[str, Any]:
"""Validate a provider's credentials with one cheap, read-only call (list models) — the same
pattern connectors use to validate tokens. Transient: callers pass the key directly so a user
can Test before saving. Never raises; returns {ok, error?}. Multi-field cloud providers
(Bedrock, Vertex) take their whole form via `fields`; everyone else uses api_key/base_url.
"""Validate a provider's credentials with one cheap live call. Transient: callers
pass the key directly so a user can Test before saving. Never raises; returns
{ok, error?}. Multi-field cloud providers (Bedrock, Vertex) take their whole form
via `fields`; everyone else uses api_key/base_url.
"""
import httpx

Expand All @@ -816,6 +886,9 @@ def verify_provider_key(
return _verify_bedrock(fields or {}, timeout)
if name == "vertex":
return _verify_vertex(fields or {}, timeout)
if name not in ("anthropic", "gemini", "ollama"):
# openai + OpenAI-compatible endpoints (Azure, OpenRouter, vendors, vLLM…)
return _verify_openai_compat(d, key, base_url, timeout)
try:
if name == "anthropic":
resp = httpx.get(
Expand All @@ -829,23 +902,9 @@ def verify_provider_key(
params={"key": key},
timeout=timeout,
)
elif name == "ollama":
else: # ollama
base = _normalize_ollama_url(base_url)
resp = httpx.get(base.rstrip("/") + "/models", timeout=timeout)
else: # openai + any OpenAI-compatible endpoint (Azure, OpenRouter, vendors, vLLM…)
default_base = next(
(f.default for f in d.fields if f.key == "base_url" and f.default), ""
)
base = (
(base_url or "").strip().rstrip("/")
or default_base.rstrip("/")
or "https://api.openai.com/v1"
)
resp = httpx.get(
base + "/models",
headers={"Authorization": f"Bearer {key}"},
timeout=timeout,
)
except Exception as exc: # DNS/connection/timeout — never let it bubble to a 500
return {
"ok": False,
Expand Down
49 changes: 41 additions & 8 deletions tests/test_provider_verify.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Tests for provider key detection + the live (read-only) Test/verify path. SDK-free: the
single httpx.get is monkeypatched so no network is touched."""
"""Tests for provider key detection + the live Test/verify path. SDK-free: httpx is
monkeypatched so no network is touched."""

from __future__ import annotations

Expand Down Expand Up @@ -41,32 +41,65 @@ def fake_get(url, **kwargs):
monkeypatch.setattr("httpx.get", fake_get)


def _patch_openai_compat(
monkeypatch,
*,
models_status=200,
completions_status=200,
model_id="demo",
capture=None,
):
def fake_get(url, **kwargs):
if capture is not None:
capture["models_url"] = url
capture["headers"] = kwargs.get("headers")
body = {"data": [{"id": model_id}]} if model_id else {"data": []}
return SimpleNamespace(status_code=models_status, json=lambda: body)

def fake_post(url, **kwargs):
if capture is not None:
capture["completions_url"] = url
return SimpleNamespace(status_code=completions_status)

monkeypatch.setattr("httpx.get", fake_get)
monkeypatch.setattr("httpx.post", fake_post)


def test_verify_openai_ok(monkeypatch):
cap: dict = {}
_patch_get(monkeypatch, status=200, capture=cap)
_patch_openai_compat(monkeypatch, capture=cap)
assert verify_provider_key("openai", api_key="sk-x") == {"ok": True}
assert cap["url"] == "https://api.openai.com/v1/models"
assert cap["models_url"] == "https://api.openai.com/v1/models"
assert cap["headers"]["Authorization"] == "Bearer sk-x"
assert cap["completions_url"] == "https://api.openai.com/v1/chat/completions"


def test_verify_openai_custom_endpoint(monkeypatch):
cap: dict = {}
_patch_get(monkeypatch, status=200, capture=cap)
_patch_openai_compat(monkeypatch, capture=cap)
verify_provider_key(
"openai", api_key="sk-x", base_url="https://gw.example/openai/v1/"
)
# trailing slash trimmed, /models appended to the custom endpoint
assert cap["url"] == "https://gw.example/openai/v1/models"
assert cap["models_url"] == "https://gw.example/openai/v1/models"
assert cap["completions_url"] == "https://gw.example/openai/v1/chat/completions"


def test_verify_bad_key_is_invalid(monkeypatch):
_patch_get(monkeypatch, status=401)
_patch_openai_compat(monkeypatch, models_status=401)
assert verify_provider_key("openai", api_key="sk-bad") == {
"ok": False,
"error": "Invalid API key.",
}


def test_verify_rejects_endpoint_when_completions_404(monkeypatch):
_patch_openai_compat(monkeypatch, completions_status=404)
res = verify_provider_key(
"qwen", api_key="placeholder", base_url="http://127.0.0.1:1234"
)
assert not res["ok"] and "/v1" in res["error"]


def test_verify_anthropic_headers(monkeypatch):
cap: dict = {}
_patch_get(monkeypatch, status=200, capture=cap)
Expand Down
Loading