diff --git a/static/index.html b/static/index.html index d427c53..014880b 100644 --- a/static/index.html +++ b/static/index.html @@ -2014,6 +2014,9 @@ +
+ +
diff --git a/static/js/admin.js b/static/js/admin.js index a551df7..11d2311 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -871,11 +871,14 @@ function initEndpointForm() { const raw = (el('adm-epLocalUrl').value || '').trim(); if (!raw) { msg.textContent = 'Enter a base URL to test'; msg.className = 'admin-error'; return; } const url = _normalizeBaseUrl(raw); + const keyEl = el('adm-epLocalApiKey'); + const apiKey = keyEl ? keyEl.value.trim() : ''; localTestBtn.disabled = true; localTestBtn.textContent = 'Testing...'; try { const fd = new FormData(); fd.append('base_url', url); + if (apiKey) fd.append('api_key', apiKey); const res = await fetch('/api/model-endpoints/test', { method: 'POST', body: fd, credentials: 'same-origin' }); const d = await res.json(); _renderEndpointTestResult(msg, res, d); @@ -894,10 +897,13 @@ function initEndpointForm() { const raw = (el('adm-epLocalUrl').value || '').trim(); if (!raw) { msg.textContent = 'Enter a base URL (e.g. http://localhost:8002/v1)'; msg.className = 'admin-error'; return; } const url = _normalizeBaseUrl(raw); + const keyEl = el('adm-epLocalApiKey'); + const apiKey = keyEl ? keyEl.value.trim() : ''; localAddBtn.disabled = true; localAddBtn.textContent = 'Adding...'; try { const fd = new FormData(); fd.append('base_url', url); + if (apiKey) fd.append('api_key', apiKey); const lt = el('adm-epLocalType'); if (lt) fd.append('model_type', lt.value); fd.append('skip_probe', 'false'); @@ -905,6 +911,7 @@ function initEndpointForm() { const d = await res.json(); if (res.ok) { el('adm-epLocalUrl').value = ''; + if (keyEl) keyEl.value = ''; if (lt) lt.value = 'llm'; if (d.id) _recentlyAddedEpId = String(d.id); await loadEndpoints(); diff --git a/tests/test_local_endpoint_api_key_js.py b/tests/test_local_endpoint_api_key_js.py new file mode 100644 index 0000000..ed04e1b --- /dev/null +++ b/tests/test_local_endpoint_api_key_js.py @@ -0,0 +1,132 @@ +"""Behavioral test for issue #353 — Local LLM endpoints behind an API key. + +The admin "Local" add/test form previously sent only `base_url` (+ model_type), +so a self-hosted endpoint protected by an API key could never be added — it just +errored out. The backend `POST /api/model-endpoints` and `/model-endpoints/test` +already accept an `api_key` form field; the fix wires the new `adm-epLocalApiKey` +input into the local Test and Add handlers. + +admin.js can't be imported standalone (browser-only deps), so — same approach as +tests/test_local_endpoint_js.py — we extract the two click-handler bodies from +source and run them under node with mocked DOM/FormData/fetch, asserting the +outgoing form data contains `api_key` exactly when the key field is filled. +""" +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +_REPO = Path(__file__).resolve().parent.parent +_ADMIN_JS = _REPO / "static" / "js" / "admin.js" +_INDEX_HTML = _REPO / "static" / "index.html" +_HAS_NODE = shutil.which("node") is not None + + +def _extract_handler_body(src: str, marker: str) -> str: + """Return the body (without the outer braces) of the arrow function that + immediately follows `marker` in `src`, using a quote-aware brace matcher.""" + start = src.index(marker) + len(marker) + brace = src.index("{", start) + i = brace + 1 + depth = 1 + quote = None + escaped = False + while i < len(src): + c = src[i] + if quote: + if escaped: + escaped = False + elif c == "\\": + escaped = True + elif c == quote: + quote = None + elif c in "'\"`": + quote = c + elif c == "{": + depth += 1 + elif c == "}": + depth -= 1 + if depth == 0: + return src[brace + 1:i] + i += 1 + raise AssertionError(f"unbalanced braces after marker: {marker!r}") + + +_HARNESS = """ +let appended = []; +class FormData {{ append(k, v) {{ appended.push([k, String(v)]); }} }} +const FIELDS = {fields}; +function el(id) {{ + if (!(id in FIELDS)) return null; + return {{ + get value() {{ return FIELDS[id]; }}, + set value(x) {{ FIELDS[id] = x; }}, + disabled: false, textContent: '', + classList: {{ add() {{}}, remove() {{}} }}, + }}; +}} +function _endpointMsg() {{ return {{ textContent: '', className: '' }}; }} +function _normalizeBaseUrl(u) {{ return u; }} +function _renderEndpointTestResult() {{}} +async function loadEndpoints() {{}} +async function _selectAddedModelInChat() {{}} +let _recentlyAddedEpId = null; +const localTestBtn = {{ disabled: false, textContent: '' }}; +const localAddBtn = {{ disabled: false, textContent: '' }}; +async function fetch() {{ + return {{ ok: true, async json() {{ return {{ id: 'x', models: [], online: true, status: 'ok' }}; }} }}; +}} +async function run() {{ {body} }} +run().then(() => console.log(JSON.stringify(appended))) + .catch((e) => {{ console.error(e); process.exit(2); }}); +""" + + +def _run_handler(body: str, fields: dict) -> list: + js = _HARNESS.format(fields=json.dumps(fields), body=body) + proc = subprocess.run( + ["node", "--input-type=module"], + input=js, capture_output=True, text=True, cwd=str(_REPO), timeout=30, + ) + assert proc.returncode == 0, f"node failed: {proc.stderr}\n---\n{js}" + return json.loads(proc.stdout.strip()) + + +def _handler(marker: str) -> str: + return _extract_handler_body(_ADMIN_JS.read_text(encoding="utf-8"), marker) + + +_TEST_MARKER = "localTestBtn.addEventListener('click', async () => " +_ADD_MARKER = "localAddBtn.addEventListener('click', async () => " + + +def test_local_form_has_api_key_input(): + html = _INDEX_HTML.read_text(encoding="utf-8") + pos = html.find('id="adm-epLocalApiKey"') + assert pos != -1, "adm-epLocalApiKey input missing from index.html" + # Isolate the enclosing tag and require it to be a masked field, + # like the cloud form's API-key input. + tag = html[html.rfind("", pos) + 1] + assert 'type="password"' in tag, f"local API key must be a password input: {tag}" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +@pytest.mark.parametrize("marker", [_TEST_MARKER, _ADD_MARKER]) +def test_api_key_sent_when_filled(marker): + fields = {"adm-epLocalUrl": "http://localhost:8002/v1", + "adm-epLocalApiKey": "sk-secret", "adm-epLocalType": "llm"} + appended = dict(_run_handler(_handler(marker), fields)) + assert appended.get("base_url") == "http://localhost:8002/v1" + assert appended.get("api_key") == "sk-secret", f"api_key not sent: {appended}" + + +@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH") +@pytest.mark.parametrize("marker", [_TEST_MARKER, _ADD_MARKER]) +def test_api_key_omitted_when_blank(marker): + fields = {"adm-epLocalUrl": "http://localhost:8002/v1", + "adm-epLocalApiKey": "", "adm-epLocalType": "llm"} + keys = [k for k, _ in _run_handler(_handler(marker), fields)] + assert "base_url" in keys + assert "api_key" not in keys, "blank key must not be appended (avoids empty Bearer)"