diff --git a/routes/cookbook_routes.py b/routes/cookbook_routes.py index 408693cb3..7d9e6e0cb 100644 --- a/routes/cookbook_routes.py +++ b/routes/cookbook_routes.py @@ -1416,6 +1416,7 @@ def setup_cookbook_routes() -> APIRouter: """Register a diffusion model as an image endpoint so it appears in the model selector.""" import re from core.database import SessionLocal, ModelEndpoint + from routes.model_routes import _normalize_model_ids from src.settings import load_settings, save_settings # Parse port from command (--port NNNN), default 8100 for diffusion_server @@ -1443,7 +1444,10 @@ def setup_cookbook_routes() -> APIRouter: if existing: existing.is_enabled = True existing.model_type = "image" - existing.name = display_name + # Follow the newly served model unless the user renamed the endpoint. + pinned_names = {f"{m.split('/')[-1]} (image)" for m in _normalize_model_ids(existing.pinned_models)} + if existing.name in pinned_names: + existing.name = display_name existing.endpoint_kind = "local" existing.model_refresh_mode = "manual" existing.cookbook_serve_session_id = serve_session_id @@ -1772,7 +1776,10 @@ def setup_cookbook_routes() -> APIRouter: if existing: existing.is_enabled = True existing.model_type = "llm" - existing.name = display_name + # Follow the newly served model unless the user renamed the endpoint. + pinned_names = {m.split("/")[-1] for m in _normalize_model_ids(existing.pinned_models)} + if existing.name in pinned_names: + existing.name = display_name existing.endpoint_kind = "local" existing.model_refresh_mode = "auto" # A reused endpoint belongs to the newest serve session. diff --git a/static/css/06-admin-settings.css b/static/css/06-admin-settings.css index 788470329..9869ef5e5 100644 --- a/static/css/06-admin-settings.css +++ b/static/css/06-admin-settings.css @@ -52,6 +52,45 @@ gap: 8px; } .admin-user-name { font-size: 13px; font-weight: 500; } +/* Inline endpoint rename: the input keeps the name's typography so the row doesn't shift */ +.adm-ep-rename-btn { + background: none; + border: none; + padding: 0 2px; + color: inherit; + line-height: 1; + cursor: pointer; + opacity: 0.45; +} +.adm-ep-rename-btn:hover, +.adm-ep-rename-btn:focus-visible { opacity: 0.9; } +@media (hover: hover) { + .adm-ep-rename-btn { opacity: 0; } + .admin-user-row:hover .adm-ep-rename-btn { opacity: 0.45; } + .admin-user-row .adm-ep-rename-btn:hover, + .adm-ep-rename-btn:focus-visible { opacity: 0.9; } +} +.admin-user-row input.adm-ep-rename-input { + width: auto; + field-sizing: content; + max-width: 100%; + height: auto; + padding: 0 0 1px; + line-height: inherit; + color: inherit; + background: transparent; + border: none; + border-bottom: 1px solid var(--red); + border-radius: 0; + outline: none; +} +.admin-user-info:has(.adm-ep-name, .adm-ep-rename-input) { min-width: 0; } +.adm-ep-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} /* Privilege panel */ .admin-priv-panel { max-height: 600px; diff --git a/static/js/admin.js b/static/js/admin.js index 375c3d1cf..1f42208d1 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -1074,7 +1074,8 @@ async function loadEndpoints() { ` : ''} - ${esc(epTitle)} + ${esc(epTitle)} + ${_endpointSelectMode || (isChatgptAccount && ep.account_label) ? '' : ``} ${ep.model_type === 'image' ? 'Image' : ''} ${kindLabel ? `${esc(kindLabel)}` : ''} ${statusBadge} @@ -1204,6 +1205,70 @@ async function loadEndpoints() { loadEndpoints(); }); }); + queryAll('[data-adm-rename-ep]').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const nameEl = btn.previousElementSibling; + if (!nameEl || !nameEl.classList.contains('admin-user-name')) return; + const original = btn.dataset.admEpTitle || ''; + const input = document.createElement('input'); + input.type = 'text'; + input.value = original; + input.placeholder = 'Endpoint name'; + input.setAttribute('aria-label', 'Endpoint name'); + input.maxLength = 64; + input.autocomplete = 'off'; + input.setAttribute('data-bwignore', ''); + input.className = 'admin-user-name adm-ep-rename-input'; + input.style.minWidth = `min(${nameEl.getBoundingClientRect().width}px, 100%)`; + nameEl.replaceWith(input); + btn.style.visibility = 'hidden'; + input.focus(); + input.select(); + let done = false; + const restore = () => { + input.replaceWith(nameEl); + btn.style.visibility = ''; + }; + const commit = async () => { + if (done) return; + done = true; + const name = input.value.trim(); + if (!name || name === original) { restore(); return; } + const epId = btn.dataset.admRenameEp; + if (data.some(ep => String(ep.id) !== epId && (ep.name || '').trim().toLowerCase() === name.toLowerCase())) { + uiModule.showError(`An endpoint named "${name}" already exists`); + done = false; + input.focus(); + return; + } + input.disabled = true; + try { + const res = await fetch(`/api/model-endpoints/${encodeURIComponent(epId)}`, { + method: 'PATCH', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data.detail || 'Failed to rename endpoint'); + } + } catch (err) { + uiModule.showError(err.message || 'Failed to rename endpoint'); + restore(); + return; + } + await _refreshAfterEndpointChange(); + loadEndpoints(); + }; + input.addEventListener('blur', commit); + input.addEventListener('keydown', (ev) => { + if (ev.key === 'Enter') { ev.preventDefault(); input.blur(); } + if (ev.key === 'Escape') { ev.preventDefault(); ev.stopPropagation(); done = true; restore(); } + }); + }); + }); queryAll('[data-adm-copy-url]').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); diff --git a/tests/test_cookbook_endpoint_registration.py b/tests/test_cookbook_endpoint_registration.py index f413eff25..802fbbf0c 100644 --- a/tests/test_cookbook_endpoint_registration.py +++ b/tests/test_cookbook_endpoint_registration.py @@ -445,3 +445,83 @@ async def test_ordinary_admin_delete_does_not_require_serve_owner( response = await client.delete(f"http://testserver/api/model-endpoints/{endpoint_id}") assert response.status_code == 200, response.text assert _endpoint(endpoint_db, endpoint_id) is None + + +def _launch_image(repo_id: str, port: int, monkeypatch) -> str: + import routes.cookbook_routes as cookbook_routes + import src.settings as settings + from routes.cookbook_helpers import ServeRequest + + monkeypatch.setattr(settings, "load_settings", lambda: {"image_gen_enabled": True, "image_model": repo_id}) + monkeypatch.setattr(settings, "save_settings", lambda _settings: None) + router = cookbook_routes.setup_cookbook_routes() + serve = next( + route.endpoint for route in router.routes + if route.path == "/api/model/serve" and "POST" in route.methods + ) + cells = dict(zip(serve.__code__.co_freevars, serve.__closure__ or ())) + register = cells["_auto_register_image_endpoint"].cell_contents + endpoint_id = register( + ServeRequest(repo_id=repo_id, cmd=f"python diffusion_server.py --model {repo_id} --port {port}"), None + ) + assert endpoint_id + return endpoint_id + + +def _rename(factory, endpoint_id: str, name: str) -> None: + import core.database as database + + with factory() as db: + db.get(database.ModelEndpoint, endpoint_id).name = name + db.commit() + + +@pytest.mark.parametrize("relaunched", ["Qwen/Qwen3-8B", "meta-llama/Llama-3.1-8B"]) +def test_relaunch_keeps_renamed_endpoint_name(endpoint_db, relaunched): + endpoint = _launch_llm("Qwen/Qwen3-8B", 8001) + _rename(endpoint_db, endpoint, "Workstation GPU") + + assert _launch_llm(relaunched, 8001) == endpoint + + assert _endpoint(endpoint_db, endpoint).name == "Workstation GPU" + + +def test_relaunch_names_endpoint_after_served_model_when_not_renamed(endpoint_db): + endpoint = _launch_llm("Qwen/Qwen3-8B", 8001) + + for repo_id, name in [ + ("meta-llama/Llama-3.1-8B", "Llama-3.1-8B"), + ("Qwen/Qwen3-8B", "Qwen3-8B"), + ("meta-llama/Llama-3.1-8B", "Llama-3.1-8B"), + ]: + _launch_llm(repo_id, 8001) + assert _endpoint(endpoint_db, endpoint).name == name + + +def test_launch_keeps_name_of_endpoint_without_pinned_models(endpoint_db): + import core.database as database + + with endpoint_db() as db: + db.add(database.ModelEndpoint(id="manual", name="My vLLM box", base_url="http://localhost:8001/v1")) + db.commit() + + assert _launch_llm("Qwen/Qwen3-8B", 8001) == "manual" + + assert _endpoint(endpoint_db, "manual").name == "My vLLM box" + + +def test_image_relaunch_keeps_renamed_endpoint_name(endpoint_db, monkeypatch): + endpoint = _launch_image("black-forest-labs/FLUX.1-schnell", 8100, monkeypatch) + _rename(endpoint_db, endpoint, "Image box") + + _launch_image("stabilityai/sdxl-turbo", 8100, monkeypatch) + + assert _endpoint(endpoint_db, endpoint).name == "Image box" + + +def test_image_relaunch_names_endpoint_after_served_model_when_not_renamed(endpoint_db, monkeypatch): + endpoint = _launch_image("black-forest-labs/FLUX.1-schnell", 8100, monkeypatch) + + _launch_image("stabilityai/sdxl-turbo", 8100, monkeypatch) + + assert _endpoint(endpoint_db, endpoint).name == "sdxl-turbo (image)"