fix(ui): refine provider endpoint controls

This commit is contained in:
Alexandre Teixeira 2026-09-22 00:36:47 +01:00
parent 46c8451a29
commit 2b2bf0fb90
3 changed files with 323 additions and 20 deletions

View file

@ -599,6 +599,44 @@ function _ensureEndpointBulkControls() {
_updateEndpointBulkControls();
}
function shouldDisplayEndpointBaseUrl(ep) {
if (!ep || !ep.base_url) return false;
if (isChatgptSubscriptionEndpoint(ep)) return false;
if (isFeatherlessEndpoint(ep)) return false;
return true;
}
function isFeatherlessEndpoint(ep) {
if (!ep) return false;
if (ep.provider === 'featherless') return true;
const url = String(ep.base_url || '').toLowerCase();
try {
const host = new URL(url).hostname;
return host === 'api.featherless.ai' || host.endsWith('.featherless.ai');
} catch (_) {
return url.includes('api.featherless.ai');
}
}
function endpointDetailHtml(ep, category) {
const showUrl = shouldDisplayEndpointBaseUrl(ep);
const copyBtn = (showUrl && category === 'local')
? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>`
: '';
const keyLabel = ep.has_key
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
const parts = [];
if (showUrl) {
parts.push(esc(ep.base_url) + copyBtn);
if (keyLabel) parts.push(keyLabel);
} else if (keyLabel) {
parts.push(keyLabel.trim());
}
if (!parts.length) return '';
return `<div class="admin-ep-detail">${parts.join('')}</div>`;
}
// ChatGPT per-endpoint usage panel expanded state persistence.
// Preserves only endpoint/auth identifiers, never tokens, secrets, or labels.
const CHATGPT_USAGE_EXPANDED_KEY = 'odysseus-chatgpt-usage-expanded';
@ -690,20 +728,24 @@ async function loadEndpoints() {
// `ep.models` is the *visible* set — when every model is hidden it's
// empty, but we still need to render the expand panel so the user can
// un-hide them. Gate on the total instead.
const hasModels = ep.online && totalCount > 0;
const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
const isFeatherless = isFeatherlessEndpoint(ep);
const hasModels = ep.online && (totalCount > 0 || isFeatherless);
const countText = (isChatgptAccount || isFeatherless)
? `${visibleCount} models enabled`
: `${visibleCount}/${totalCount} models enabled`;
const statusBadge = ep.status === 'empty'
? '<span class="admin-badge">no models</span>'
: ep.online
? `<span class="admin-badge">${visibleCount}/${totalCount} models enabled</span>`
? `<span class="admin-badge">${countText}</span>`
: '<span class="admin-badge admin-badge-off">offline</span>';
const justAddedClass = (_recentlyAddedEpId && String(ep.id) === _recentlyAddedEpId) ? ' adm-ep-just-added' : '';
const category = ep.category || (_isLocalEndpoint(ep.base_url) ? 'local' : 'api');
const kindLabel = ep.endpoint_kind && ep.endpoint_kind !== 'auto' ? ep.endpoint_kind.toUpperCase() : '';
const keyLabel = ep.has_key
? (ep.api_key_fingerprint ? ` (key ${esc(ep.api_key_fingerprint)})` : ' (key set)')
: '';
const isChatgptAccount = isChatgptSubscriptionEndpoint(ep);
const isUsageExpanded = isChatgptAccount && _isChatgptUsageExpanded(ep.id, ep.provider_auth_id);
const epTitle = isChatgptAccount
? chatgptAccountTitle(ep)
: (isFeatherless && (!ep.name || ep.name === 'api.featherless.ai') ? 'Featherless.ai' : ep.name);
return `
<div class="admin-user-row${ep.is_enabled ? '' : ' admin-ep-disabled'}${justAddedClass}" data-adm-ep-id="${ep.id}">
<div style="display:flex;align-items:center;justify-content:space-between;${hasModels ? 'cursor:pointer;' : ''}padding:4px 0;" data-adm-ep-header="${ep.id}">
@ -713,7 +755,7 @@ async function loadEndpoints() {
<span class="adm-check-dot adm-endpoint-select-dot" aria-hidden="true"></span>
</label>` : ''}
<span class="adm-ep-row-logo" style="display:inline-flex;align-items:center;justify-content:center;width:16px;height:16px;flex-shrink:0;opacity:0.9;">${providerLogoFromUrl(ep.base_url) || ''}</span>
<span class="admin-user-name">${esc(isChatgptAccount ? chatgptAccountTitle(ep) : ep.name)}</span>
<span class="admin-user-name">${esc(epTitle)}</span>
${ep.model_type === 'image' ? '<span class="admin-badge" style="background:color-mix(in srgb, var(--accent) 20%, transparent);color:var(--accent);">Image</span>' : ''}
${kindLabel ? `<span class="admin-badge">${esc(kindLabel)}</span>` : ''}
${statusBadge}
@ -735,7 +777,7 @@ async function loadEndpoints() {
<button type="button" class="admin-btn-sm" data-adm-chatgpt-reconnect="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}">Reconnect</button>
</div>
<div id="adm-chatgpt-usage-${esc(ep.id)}" class="adm-chatgpt-usage-host${isUsageExpanded ? '' : ' hidden'}" data-adm-chatgpt-usage-host="${esc(ep.provider_auth_id)}" data-chatgpt-endpoint-id="${esc(ep.id)}"${isUsageExpanded ? '' : ' style="display:none;"'}><div class="adm-chatgpt-usage adm-chatgpt-usage-loading"><div class="adm-chatgpt-usage-status">Loading usage...</div></div></div>` : ''}
<div class="admin-ep-detail">${esc(ep.base_url)}${category === 'local' ? `<button type="button" class="admin-ep-copy-btn" data-adm-copy-url="${esc(ep.base_url)}" title="Copy URL" aria-label="Copy URL" style="background:none;border:none;padding:0 2px;margin-left:6px;cursor:pointer;color:inherit;opacity:0.45;vertical-align:-2px;line-height:1;"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg></button>` : ''}${keyLabel}</div>
${endpointDetailHtml(ep, category)}
${hasModels ? `<div class="mcp-tools-panel hidden" data-adm-ep-models-panel="${ep.id}"></div>` : ''}
</div>`;
});
@ -994,19 +1036,21 @@ async function loadEndpoints() {
const mode = ['none', 'compact', 'full'].includes(String(m.tool_mode || '').toLowerCase())
? String(m.tool_mode).toLowerCase()
: '';
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row" style="display:flex;align-items:center;gap:8px;">
<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;">
return `<div title="${esc(m.id)}" data-ep-model-row data-search="${esc((m.display + ' ' + m.id).toLowerCase())}" class="adm-model-row">
<label class="adm-model-label">
<input type="checkbox" class="adm-cb-hidden" data-ep-model-id="${esc(m.id)}" ${(usesPinnedPicker ? m.is_pinned : !m.is_hidden) ? 'checked' : ''}>
<span class="adm-check-dot" aria-hidden="true"></span>
<span style="min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(m.display)}</span>
<span class="adm-model-name">${esc(m.display)}</span>
</label>
<span title="Select the tool schema profile for this model" style="font-size:10px;opacity:0.45;flex-shrink:0;">Tools</span>
<select class="adm-model-tool-mode" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model" style="height:24px;font-size:11px;max-width:170px;flex-shrink:0;">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
<div class="adm-model-tools-col">
<span class="adm-model-tools-label" title="Select the tool schema profile for this model">Tools</span>
<select class="adm-model-tool-mode admin-tools-select" data-ep-model-id="${esc(m.id)}" data-original-tool-mode="${esc(m.tool_mode || '')}" data-tool-mode-touched="0" title="Auto uses Odysseus compact for Odysseus/Ajax names and Regular tools for every other model">
<option value="" ${mode === '' ? 'selected' : ''}>Auto</option>
<option value="full" ${mode === 'full' ? 'selected' : ''}>Regular tools</option>
<option value="compact" ${mode === 'compact' ? 'selected' : ''}>Odysseus compact</option>
<option value="none" ${mode === 'none' ? 'selected' : ''}>Tools off</option>
</select>
</div>
</div>`;
}
).join('') + '</div>';
@ -3765,5 +3809,7 @@ export function close() {
settingsModule.close();
}
export { shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml };
const adminModule = { open, close, _initData, get _initialized() { return initialized; } };
export default adminModule;

View file

@ -17954,6 +17954,69 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
transform-origin: center;
transition: transform 0.2s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.adm-model-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 28px;
padding: 2px 4px;
box-sizing: border-box;
width: 100%;
}
.adm-model-label {
display: flex;
align-items: center;
gap: 8px;
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
}
.adm-model-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
.adm-model-tools-col {
display: inline-flex;
align-items: center;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
}
.adm-model-tools-label {
font-size: 10px;
opacity: 0.45;
flex-shrink: 0;
user-select: none;
}
.adm-model-tool-mode,
.admin-tools-select {
height: 24px;
min-height: 24px;
line-height: 22px;
font-size: 11px;
padding: 0 18px 0 6px;
width: 124px;
max-width: 130px;
flex-shrink: 0;
border-radius: 4px;
border: 1px solid var(--border);
background-color: var(--select-bg);
color: var(--select-fg);
box-sizing: border-box;
vertical-align: middle;
}
@media (max-width: 480px) {
.adm-model-tool-mode,
.admin-tools-select {
width: 105px;
max-width: 110px;
font-size: 10px;
padding: 0 14px 0 4px;
}
}
.adm-model-row:hover .adm-check-dot {
border-color: var(--accent, var(--red));
transform: scale(1.15);
@ -49631,9 +49694,10 @@ body.theme-frosted .modal {
/* ChatGPT Subscription per-account usage card (Settings → Added Models) */
.adm-chatgpt-controls {
display: flex;
justify-content: flex-end;
gap: 6px;
align-items: center;
margin: 4px 0 4px 22px;
margin: 4px 0 2px auto;
}
.adm-chatgpt-usage-chevron {
font-size: 10px;
@ -49642,7 +49706,7 @@ body.theme-frosted .modal {
margin-left: 2px;
}
.adm-chatgpt-usage-host {
margin: 4px 0 6px 22px;
margin: 4px 0 6px 0;
}
.adm-chatgpt-usage {
display: flex;

View file

@ -0,0 +1,193 @@
"""Tests for Part A — Endpoint Card UI Polish and Model Tools Selector Layout."""
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"
_ADMIN = _ADMIN_JS.read_text(encoding="utf-8")
_STYLE = (_REPO / "static" / "style.css").read_text(encoding="utf-8")
pytestmark = pytest.mark.skipif(not shutil.which("node"), reason="node not on PATH")
def _run_node(script: str):
proc = subprocess.run(
["node", "--input-type=module"],
input=script,
capture_output=True,
text=True,
cwd=str(_REPO),
timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
class TestEndpointCardUrlPresentation:
def test_should_display_endpoint_base_url_policy(self):
js = f"""
import fs from 'node:fs';
import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}';
const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8');
const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl');
const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart);
const fnCode = source.slice(fnStart, fnEnd);
const fns = new Function('isChatgptSubscriptionEndpoint', 'esc',
fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }};'
)(isChatgptSubscriptionEndpoint, x => String(x));
const results = {{
chatgpt: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://chatgpt.com/backend-api/codex',
provider: 'chatgpt-subscription',
provider_auth_id: 'auth-123'
}}),
featherless: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://api.featherless.ai/v1',
name: 'Featherless.ai'
}}),
featherless_subdomain: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://eu.featherless.ai/v1'
}}),
generic: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'https://api.example.com/v1',
name: 'Custom AI'
}}),
local: fns.shouldDisplayEndpointBaseUrl({{
base_url: 'http://127.0.0.1:11434',
name: 'Ollama'
}}),
null_ep: fns.shouldDisplayEndpointBaseUrl(null)
}};
console.log(JSON.stringify(results));
"""
out = _run_node(js)
assert out["chatgpt"] is False, "ChatGPT raw transport URL must be hidden"
assert out["featherless"] is False, "Featherless raw transport URL must be hidden"
assert out["featherless_subdomain"] is False, "Featherless domain variants must be hidden"
assert out["generic"] is True, "Generic custom endpoint URL must remain visible"
assert out["local"] is True, "Local endpoint URL must remain visible"
assert out["null_ep"] is False
def test_endpoint_detail_html_suppresses_first_class_urls_and_keeps_others(self):
js = f"""
import fs from 'node:fs';
import {{ isChatgptSubscriptionEndpoint }} from '{(_REPO / 'static' / 'js' / 'chatgptSubscriptionUsage.js').as_posix()}';
const source = fs.readFileSync('{_ADMIN_JS.as_posix()}', 'utf8');
const fnStart = source.indexOf('function shouldDisplayEndpointBaseUrl');
const fnEnd = source.indexOf('// ChatGPT per-endpoint usage panel', fnStart);
const fnCode = source.slice(fnStart, fnEnd);
const fns = new Function('isChatgptSubscriptionEndpoint', 'esc',
fnCode + '; return {{ shouldDisplayEndpointBaseUrl, isFeatherlessEndpoint, endpointDetailHtml }};'
)(isChatgptSubscriptionEndpoint, x => String(x));
const chatgptEp = {{
base_url: 'https://chatgpt.com/backend-api/codex',
provider: 'chatgpt-subscription',
provider_auth_id: 'auth-1',
has_key: false
}};
const featherlessEp = {{
base_url: 'https://api.featherless.ai/v1',
name: 'Featherless.ai',
has_key: true,
api_key_fingerprint: 'abcd1234'
}};
const genericEp = {{
base_url: 'https://api.custom.com/v1',
name: 'Custom',
has_key: true,
api_key_fingerprint: 'ef5678'
}};
const localEp = {{
base_url: 'http://127.0.0.1:11434',
name: 'Local',
has_key: false
}};
const results = {{
chatgptHtml: fns.endpointDetailHtml(chatgptEp, 'api'),
featherlessHtml: fns.endpointDetailHtml(featherlessEp, 'api'),
genericHtml: fns.endpointDetailHtml(genericEp, 'api'),
localHtml: fns.endpointDetailHtml(localEp, 'local')
}};
console.log(JSON.stringify(results));
"""
out = _run_node(js)
# ChatGPT card suppresses raw base URL and has no key, so no detail line
assert "chatgpt.com" not in out["chatgptHtml"]
assert out["chatgptHtml"] == ""
# Featherless suppresses raw base URL but can display key fingerprint if present
assert "api.featherless.ai" not in out["featherlessHtml"]
assert "abcd1234" in out["featherlessHtml"]
# Generic custom endpoint retains URL and key
assert "https://api.custom.com/v1" in out["genericHtml"]
assert "ef5678" in out["genericHtml"]
# Local endpoint retains URL and copy affordance
assert "http://127.0.0.1:11434" in out["localHtml"]
assert 'data-adm-copy-url="http://127.0.0.1:11434"' in out["localHtml"]
assert "admin-ep-copy-btn" in out["localHtml"]
class TestChatGPTCardLayoutAndControls:
def test_chatgpt_controls_secondary_action_layout(self):
# Verify right-alignment in style.css
assert ".adm-chatgpt-controls {" in _STYLE
controls_block = _STYLE.split(".adm-chatgpt-controls {")[1].split("}")[0]
assert "justify-content: flex-end" in controls_block
assert "display: flex" in controls_block
def test_chatgpt_usage_attributes_and_lazy_fetch_retained(self):
assert 'aria-controls="adm-chatgpt-usage-${esc(ep.id)}"' in _ADMIN
assert 'aria-expanded="${isUsageExpanded ? \'true\' : \'false\'}"' in _ADMIN
assert "data-adm-chatgpt-usage-toggle" in _ADMIN
assert "data-adm-chatgpt-reconnect" in _ADMIN
assert "adm-chatgpt-usage-host" in _ADMIN
# Lazy loading on toggle click
assert "_loadChatgptUsage(host, authId, epId)" in _ADMIN
assert "_isChatgptUsageExpanded(epId, authId)" in _ADMIN
class TestModelRowToolsSelectLayout:
def test_css_classes_prevent_clipping_and_guarantee_layout(self):
# Audit classes in style.css
assert ".adm-model-row {" in _STYLE
assert ".adm-model-label {" in _STYLE
assert ".adm-model-name {" in _STYLE
assert ".adm-model-tools-col {" in _STYLE
assert ".adm-model-tool-mode," in _STYLE or ".adm-model-tool-mode {" in _STYLE
assert ".admin-tools-select" in _STYLE
# Verify tool-mode select styling
tool_mode_css = _STYLE.split(".adm-model-tool-mode,")[1].split("}")[0]
assert "height: 24px" in tool_mode_css
assert "line-height: 22px" in tool_mode_css
assert "padding: 0 18px 0 6px" in tool_mode_css
assert "box-sizing: border-box" in tool_mode_css
assert "width: 124px" in tool_mode_css
assert "vertical-align: middle" in tool_mode_css
# Verify right column is fixed / shrink-safe
col_css = _STYLE.split(".adm-model-tools-col {")[1].split("}")[0]
assert "flex: 0 0 auto" in col_css
assert "margin-left: auto" in col_css
# Verify model name is flexible with ellipsis
name_css = _STYLE.split(".adm-model-name {")[1].split("}")[0]
assert "overflow: hidden" in name_css
assert "text-overflow: ellipsis" in name_css
assert "white-space: nowrap" in name_css
assert "flex: 1" in name_css
def test_admin_js_uses_classes_instead_of_inline_clipping_styles(self):
assert 'class="adm-model-row"' in _ADMIN
assert 'class="adm-model-label"' in _ADMIN
assert 'class="adm-model-name"' in _ADMIN
assert 'class="adm-model-tools-col"' in _ADMIN
assert 'class="adm-model-tool-mode admin-tools-select"' in _ADMIN