Models: allow API keys for local endpoints
Self-hosted endpoints on a LAN are sometimes protected by an API key. The admin "Local" add/test form only sent base_url (+ model_type), so such an endpoint could not be added — it just errored out — even though the backend POST /api/model-endpoints and /model-endpoints/test already accept an optional api_key form field (the cloud "API" form already uses it). Adds an optional masked "API key" input (adm-epLocalApiKey) to the Local form and wires it into the local Test and Add handlers, sending api_key only when filled (an empty value is omitted so we never send a blank Bearer). The field is cleared after a successful add, matching the cloud form. Tested: tests/test_local_endpoint_api_key_js.py extracts the two click handlers and runs them under node with mocked DOM/FormData/fetch, asserting api_key is sent when the field is filled and omitted when blank, plus that the input exists as a password field. `node --check static/js/admin.js` passes. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -2014,6 +2014,9 @@
|
||||
<option value="image">Image</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<input id="adm-epLocalApiKey" type="password" placeholder="API key (optional — for protected local endpoints)" autocomplete="off" style="flex:1">
|
||||
</div>
|
||||
<div class="admin-model-form-row">
|
||||
<span style="flex:1"></span>
|
||||
<button class="admin-btn-sm" id="adm-epLocalTestBtn" style="width:55px;text-align:center;">Test</button>
|
||||
|
||||
@@ -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();
|
||||
|
||||
132
tests/test_local_endpoint_api_key_js.py
Normal file
132
tests/test_local_endpoint_api_key_js.py
Normal file
@@ -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 <input ...> tag and require it to be a masked field,
|
||||
# like the cloud form's API-key input.
|
||||
tag = html[html.rfind("<input", 0, pos):html.index(">", 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)"
|
||||
Reference in New Issue
Block a user