fix(web): avoid blocking provider validation
This commit is contained in:
parent
2000278874
commit
9645ea8d52
|
|
@ -7392,8 +7392,8 @@ async def validate_custom_endpoint(body: CustomEndpointUpdate):
|
|||
headers["Authorization"] = f"Bearer {body.api_key.strip()}"
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(8.0)) as client:
|
||||
resp = client.get(url, headers=headers)
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0)) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
except Exception:
|
||||
return {"ok": False, "reachable": False, "message": f"Could not reach {url}.", "models": []}
|
||||
|
||||
|
|
@ -7434,8 +7434,8 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
|
|||
api_key = (body.api_key or "").strip()
|
||||
headers = {"Authorization": f"Bearer {api_key}"} if api_key else None
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(8.0)) as client:
|
||||
resp = client.get(url, headers=headers)
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0)) as client:
|
||||
resp = await client.get(url, headers=headers)
|
||||
return {"ok": True, "reachable": True, "message": "", "models": _parse_model_ids(resp)}
|
||||
except Exception:
|
||||
return {"ok": False, "reachable": False, "message": f"Could not reach {url}."}
|
||||
|
|
@ -7454,8 +7454,8 @@ async def validate_provider_credential(body: EnvVarUpdate, request: Request):
|
|||
params["key"] = value
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=httpx.Timeout(10.0)) as client:
|
||||
resp = client.get(url, headers=headers, params=params)
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(10.0)) as client:
|
||||
resp = await client.get(url, headers=headers, params=params)
|
||||
except Exception:
|
||||
return {"ok": False, "reachable": False, "message": "Could not reach the provider to verify the key."}
|
||||
|
||||
|
|
|
|||
|
|
@ -3660,10 +3660,9 @@ class TestDashboardPluginStaticAssetAllowlist:
|
|||
assert resp.status_code in (403, 404)
|
||||
|
||||
|
||||
def _fake_httpx_client(*, status: int | None = None, raise_exc: bool = False):
|
||||
"""Build a drop-in for httpx.Client whose .get() returns a canned status
|
||||
(or raises a transport error). Patched in for the credential-validate probe
|
||||
so tests never touch the network."""
|
||||
def _fake_httpx_async_client(*, status: int | None = None, raise_exc: bool = False):
|
||||
"""Build a drop-in for httpx.AsyncClient with a canned GET response."""
|
||||
|
||||
class _Resp:
|
||||
def __init__(self, code):
|
||||
self.status_code = code
|
||||
|
|
@ -3676,13 +3675,13 @@ def _fake_httpx_client(*, status: int | None = None, raise_exc: bool = False):
|
|||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, *a, **k):
|
||||
async def get(self, *a, **k):
|
||||
if raise_exc:
|
||||
raise RuntimeError("connection refused")
|
||||
return _Resp(status)
|
||||
|
|
@ -3705,13 +3704,38 @@ class TestValidateProviderCredential:
|
|||
self.client = TestClient(app)
|
||||
self.client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
|
||||
|
||||
class _BlockingClient:
|
||||
def __init__(self, *args, **kwargs):
|
||||
raise AssertionError(
|
||||
"async validation route used blocking httpx.Client"
|
||||
)
|
||||
|
||||
monkeypatch.setattr("httpx.Client", _BlockingClient)
|
||||
|
||||
def _post(self, key, value):
|
||||
return self.client.post("/api/providers/validate", json={"key": key, "value": value})
|
||||
return self.client.post(
|
||||
"/api/providers/validate", json={"key": key, "value": value}
|
||||
)
|
||||
|
||||
def test_rejected_key_blocks(self, monkeypatch):
|
||||
monkeypatch.setattr("httpx.AsyncClient", _fake_httpx_async_client(status=401))
|
||||
data = self._post("OPENROUTER_API_KEY", "sk-bogus").json()
|
||||
assert data["ok"] is False and data["reachable"] is True
|
||||
|
||||
def test_valid_key_passes(self, monkeypatch):
|
||||
monkeypatch.setattr("httpx.AsyncClient", _fake_httpx_async_client(status=200))
|
||||
data = self._post("OPENAI_API_KEY", "sk-real").json()
|
||||
assert data["ok"] is True and data["reachable"] is True
|
||||
|
||||
def test_rate_limited_counts_as_valid(self, monkeypatch):
|
||||
monkeypatch.setattr("httpx.AsyncClient", _fake_httpx_async_client(status=429))
|
||||
data = self._post("XAI_API_KEY", "xai-real").json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_network_error_is_unreachable_not_blocking(self, monkeypatch):
|
||||
monkeypatch.setattr("httpx.Client", _fake_httpx_client(raise_exc=True))
|
||||
monkeypatch.setattr(
|
||||
"httpx.AsyncClient", _fake_httpx_async_client(raise_exc=True)
|
||||
)
|
||||
data = self._post("OPENROUTER_API_KEY", "sk-real").json()
|
||||
assert data["ok"] is False and data["reachable"] is False
|
||||
|
||||
|
|
@ -3734,18 +3758,18 @@ class TestValidateProviderCredential:
|
|||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
def get(self, url, *a, headers=None, **k):
|
||||
async def get(self, url, *a, headers=None, **k):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("httpx.Client", _Client)
|
||||
monkeypatch.setattr("httpx.AsyncClient", _Client)
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/providers/validate",
|
||||
|
|
@ -3761,6 +3785,91 @@ class TestValidateProviderCredential:
|
|||
assert captured["url"] == "https://text.example.com/v1/models"
|
||||
assert captured["headers"] == {"Authorization": "Bearer sk-secret"}
|
||||
|
||||
def test_local_endpoint_without_key_sends_no_auth_header(self, monkeypatch):
|
||||
"""No key → no Authorization header (keyless local servers unaffected)."""
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
is_success = True
|
||||
|
||||
def json(self):
|
||||
return {"data": []}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *a, **k):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *a):
|
||||
return False
|
||||
|
||||
async def get(self, url, *a, headers=None, **k):
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("httpx.AsyncClient", _Client)
|
||||
|
||||
self.client.post(
|
||||
"/api/providers/validate",
|
||||
json={"key": "OPENAI_BASE_URL", "value": "http://127.0.0.1:8000/v1"},
|
||||
)
|
||||
assert captured["headers"] is None
|
||||
|
||||
def test_named_custom_endpoint_probe_is_async(self, monkeypatch):
|
||||
"""Custom endpoint validation must not block the dashboard event loop."""
|
||||
captured = {}
|
||||
|
||||
class _Resp:
|
||||
status_code = 200
|
||||
is_success = True
|
||||
|
||||
def json(self):
|
||||
return {"data": [{"id": "local-model"}]}
|
||||
|
||||
class _Client:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args):
|
||||
return False
|
||||
|
||||
async def get(self, url, *args, headers=None, **kwargs):
|
||||
captured["url"] = url
|
||||
captured["headers"] = headers
|
||||
return _Resp()
|
||||
|
||||
monkeypatch.setattr("httpx.AsyncClient", _Client)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/providers/custom-endpoints/validate",
|
||||
json={
|
||||
"name": "Local",
|
||||
"base_url": "http://localhost:8000/v1",
|
||||
"model": "local-model",
|
||||
"api_key": "local-secret",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.json() == {
|
||||
"ok": True,
|
||||
"reachable": True,
|
||||
"message": "",
|
||||
"models": ["local-model"],
|
||||
}
|
||||
assert captured == {
|
||||
"url": "http://localhost:8000/v1/models",
|
||||
"headers": {
|
||||
"Accept": "application/json",
|
||||
"Authorization": "Bearer local-secret",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class TestDesktopCronTicker:
|
||||
"""The dashboard backend fires cron jobs itself only when desktop-spawned."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue