Update proxy-aware admin URLs and spatial model support

parent c2915d44
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}CoderAI{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ root_path }}/static/admin/style.css">
<script>const ROOT_PATH = "{{ root_path }}";</script>
{% block head %}{% endblock %}
</head>
<body>
{% if username %}
<nav class="topnav">
<div class="topnav-inner">
<div class="topnav-left">
<a href="{{ root_path }}/admin" class="nav-logo">
<div class="nav-logo-mark">AI</div>
<span class="nav-logo-name">CoderAI</span>
</a>
<div class="nav-links">
<a href="{{ root_path }}/admin" class="nav-link {% if request.url.path == '/admin' %}active{% endif %}">Overview</a>
<a href="{{ root_path }}/chat" class="nav-link {% if request.url.path == '/chat' %}active{% endif %}">Studio</a>
<a href="{{ root_path }}/docs" class="nav-link" target="_blank">API Docs</a>
{% if is_admin|default(false) %}
<a href="{{ root_path }}/admin/models" class="nav-link {% if '/models' in request.url.path %}active{% endif %}">Models</a>
<a href="{{ root_path }}/admin/tokens" class="nav-link {% if '/tokens' in request.url.path %}active{% endif %}">Tokens</a>
<a href="{{ root_path }}/admin/users" class="nav-link {% if '/users' in request.url.path %}active{% endif %}">Users</a>
<a href="{{ root_path }}/admin/archive" class="nav-link {% if '/archive' in request.url.path %}active{% endif %}">Archive</a>
<a href="{{ root_path }}/admin/settings" class="nav-link {% if '/settings' in request.url.path %}active{% endif %}">Settings</a>
{% endif %}
<button class="nav-link nav-donate-btn" onclick="document.getElementById('donateModal').classList.add('show')">&#9829; Donate</button>
</div>
</div>
<div class="topnav-right">
<span class="nav-username">{{ username }}</span>
<div class="nav-sep"></div>
<a href="{{ root_path }}/logout" class="nav-logout">Sign out</a>
</div>
</div>
</nav>
<div class="modal" id="donateModal" onclick="if(event.target===this)this.classList.remove('show')">
<div class="modal-box donate-modal-box">
<div class="modal-head">
<span class="modal-title">Support CoderAI</span>
<button class="modal-close" onclick="document.getElementById('donateModal').classList.remove('show')">&times;</button>
</div>
<div class="modal-body">
<p class="donate-tagline">CoderAI is free and open source. If you find it useful, consider supporting its development — every contribution helps.</p>
<div class="donate-coins">
<div class="donate-coin">
<span class="donate-coin-label">Bitcoin &mdash; BTC</span>
<img class="donate-qr" src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&color=DDE1F0&bgcolor=161820&qzone=2&data=bitcoin:bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u" alt="BTC QR code" width="160" height="160">
<div class="donate-addr-row">
<code class="donate-addr" id="btcAddr">bc1qcpt2uutqkz4456j5r78rjm3gwq03h5fpwmcc5u</code>
<button class="donate-copy" onclick="donateCopy('btcAddr',this)">Copy</button>
</div>
</div>
<div class="donate-coin">
<span class="donate-coin-label">Ethereum &mdash; ETH</span>
<img class="donate-qr" src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&color=DDE1F0&bgcolor=161820&qzone=2&data=ethereum:0xdA6dAb526515b5cb556d20269207D43fcc760E51" alt="ETH QR code" width="160" height="160">
<div class="donate-addr-row">
<code class="donate-addr" id="ethAddr">0xdA6dAb526515b5cb556d20269207D43fcc760E51</code>
<button class="donate-copy" onclick="donateCopy('ethAddr',this)">Copy</button>
</div>
</div>
<div class="donate-coin">
<span class="donate-coin-label">PayPal</span>
<img class="donate-qr" src="https://api.qrserver.com/v1/create-qr-code/?size=160x160&color=DDE1F0&bgcolor=161820&qzone=2&data=https://paypal.me/nexlab" alt="PayPal QR code" width="160" height="160">
<div class="donate-addr-row">
<a class="donate-addr donate-paypal-link" id="ppAddr" href="https://paypal.me/nexlab" target="_blank" rel="noopener">paypal.me/nexlab</a>
<a class="donate-copy" href="https://paypal.me/nexlab" target="_blank" rel="noopener">Open</a>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
function donateCopy(id, btn) {
var el = document.getElementById(id);
var text = el.href || el.textContent;
navigator.clipboard.writeText(text.trim()).then(function() {
var orig = btn.innerHTML;
btn.innerHTML = '&#10003;';
setTimeout(function(){ btn.innerHTML = orig; }, 1500);
});
}
</script>
<main class="main">
{% endif %}
<div class="{% block wrapper_class %}container{% endblock %}">
{% block content %}{% endblock %}
</div>
{% if username %}
</main>
{% endif %}
{% block scripts %}{% endblock %}
</body>
</html>
This source diff could not be displayed because it is too large. You can view the blob instead.
@import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
:root {
--bg: #08090D;
--nav: #0C0D13;
--card: #111218;
--raised: #161820;
--border: #1A1D28;
--border-2: #252836;
--text: #DDE1F0;
--text-2: #8B90A8;
--text-3: #555A72;
--accent: #6366F1;
--accent-s: rgba(99,102,241,.12);
--green: #34D399;
--amber: #F59E0B;
--red: #F87171;
--font: 'Plus Jakarta Sans', system-ui, sans-serif;
--mono: 'JetBrains Mono', monospace;
}
*,*::before,*::after{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth;scrollbar-color:var(--border-2) var(--nav)}
body{font-family:var(--font);font-size:14px;background:var(--bg);color:var(--text);line-height:1.5;-webkit-font-smoothing:antialiased}
a{color:inherit;text-decoration:none}
button,input,select,textarea{font-family:inherit;color-scheme:dark}
body,.table-wrap,.modal-box,.chat-messages,.studio .model-list,.studio .chat-msgs,.studio .gen-ctrl,.studio .gen-out,.studio .pipe-panel{scrollbar-width:thin;scrollbar-color:var(--border-2) transparent}
*::-webkit-scrollbar{width:10px;height:10px}
*::-webkit-scrollbar-track{background:transparent}
*::-webkit-scrollbar-thumb{background:var(--border-2);border-radius:999px;border:2px solid transparent;background-clip:padding-box}
*::-webkit-scrollbar-thumb:hover{background:#363b4f;border:2px solid transparent;background-clip:padding-box}
/* ── Topnav ──────────────────────────────────────────────────────── */
.topnav{
position:sticky;top:0;z-index:200;
height:44px;
background:var(--nav);
border-bottom:1px solid var(--border);
}
.topnav-inner{
max-width:1200px;margin:0 auto;padding:0 1.5rem;
height:100%;display:flex;align-items:center;justify-content:space-between;gap:1.5rem;
}
.topnav-left{display:flex;align-items:center;gap:1.75rem}
.topnav-right{display:flex;align-items:center;gap:.625rem}
/* logo */
.nav-logo{display:flex;align-items:center;gap:.5rem;flex-shrink:0}
.nav-logo-mark{
width:22px;height:22px;
background:var(--accent);
border-radius:5px;
display:flex;align-items:center;justify-content:center;
font-size:9px;font-weight:700;color:#fff;letter-spacing:-.01em;
}
.nav-logo-name{font-size:13px;font-weight:700;letter-spacing:-.01em}
/* nav links */
.nav-links{display:flex;align-items:center;gap:1px}
.nav-link{
padding:.3125rem .625rem;
font-size:13px;font-weight:500;
color:var(--text-2);
border-radius:5px;
transition:color .1s,background .1s;
white-space:nowrap;
}
.nav-link:hover{color:var(--text);background:rgba(255,255,255,.04)}
.nav-link.active{color:var(--text);background:var(--accent-s)}
/* user + logout */
.nav-username{font-size:12.5px;color:var(--text-2)}
.nav-sep{width:1px;height:14px;background:var(--border-2)}
.nav-logout{
font-size:12.5px;color:var(--text-3);
padding:.25rem .5rem;border:1px solid var(--border);border-radius:4px;
transition:all .1s;cursor:pointer;background:transparent;
}
.nav-logout:hover{color:var(--text-2);border-color:var(--border-2)}
/* ── Main ────────────────────────────────────────────────────────── */
.main{min-height:calc(100vh - 44px)}
.container{max-width:1100px;margin:0 auto;padding:2rem 1.5rem}
/* ── Page header ─────────────────────────────────────────────────── */
.page-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:1.5rem;gap:1rem}
.page-header h1{font-size:1.125rem;font-weight:700;letter-spacing:-.01em}
.page-header p{font-size:12.5px;color:var(--text-2);margin-top:.2rem}
.header-actions{display:flex;gap:.5rem;flex-shrink:0;align-items:center}
/* ── Cards ───────────────────────────────────────────────────────── */
.card{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:1.25rem;margin-bottom:1rem}
.card-title{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-2);margin-bottom:1rem}
/* ── Stat grid ───────────────────────────────────────────────────── */
.stat-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.75rem;margin-bottom:1rem}
.stat{background:var(--card);border:1px solid var(--border);border-radius:8px;padding:1.125rem}
.stat-label{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);margin-bottom:.5rem}
.stat-value{font-size:1.625rem;font-weight:700;letter-spacing:-.02em;line-height:1}
.stat-sub{font-size:11.5px;color:var(--text-3);margin-top:.375rem;font-family:var(--mono)}
/* live dot */
.live{display:inline-flex;align-items:center;gap:.375rem;font-size:11.5px;font-weight:600;color:var(--green);font-family:var(--mono)}
.live::before{content:'';width:5px;height:5px;border-radius:50%;background:var(--green);box-shadow:0 0 4px var(--green);animation:blink 2s ease infinite}
@keyframes blink{0%,100%{opacity:1}50%{opacity:.25}}
/* progress */
.progress{height:3px;background:var(--raised);border-radius:2px;margin-top:.75rem;overflow:hidden}
.progress-fill{height:100%;background:var(--accent);transition:width .5s}
.progress-labels{display:flex;justify-content:space-between;font-size:11px;color:var(--text-3);margin-top:.3rem;font-family:var(--mono)}
/* ── Buttons ─────────────────────────────────────────────────────── */
.btn{
display:inline-flex;align-items:center;gap:.375rem;
padding:.375rem .875rem;border:none;border-radius:6px;
font-size:13px;font-weight:600;cursor:pointer;
transition:all .1s;white-space:nowrap;line-height:1.4;
}
.btn svg{width:13px;height:13px;stroke-width:2;flex-shrink:0}
.btn-primary{background:var(--accent);color:#fff}
.btn-primary:hover{background:#7577F3}
.btn-secondary{background:var(--raised);color:var(--text);border:1px solid var(--border)}
.btn-secondary:hover{background:var(--border);border-color:var(--border-2)}
.btn-ghost{background:transparent;color:var(--text-2);border:1px solid var(--border)}
.btn-ghost:hover{color:var(--text);border-color:var(--border-2)}
.btn-danger{background:rgba(248,113,113,.08);color:var(--red);border:1px solid rgba(248,113,113,.2)}
.btn-danger:hover{background:rgba(248,113,113,.15);border-color:rgba(248,113,113,.4)}
.btn-sm{padding:.25rem .625rem;font-size:12px}
.btn-sm svg{width:11px;height:11px}
.btn:disabled{opacity:.4;cursor:not-allowed}
/* ── Forms ───────────────────────────────────────────────────────── */
.form-row{margin-bottom:1rem}
.form-label{display:block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-2);margin-bottom:.35rem}
.form-input{
width:100%;padding:.5rem .75rem;
background:var(--raised);border:1px solid var(--border);border-radius:6px;
color:var(--text);font-size:13.5px;
transition:border-color .1s,box-shadow .1s;
}
.form-input:focus{outline:none;border-color:var(--accent);box-shadow:0 0 0 3px rgba(99,102,241,.12)}
.form-input::placeholder{color:var(--text-3)}
select.form-input,
textarea.form-input{background:var(--raised);color:var(--text)}
select.form-input{
cursor:pointer;appearance:none;
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='5' viewBox='0 0 10 5'%3E%3Cpath d='M0 0l5 5 5-5z' fill='%23363A4D'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right .75rem center;background-size:8px;padding-right:2rem;
}
select.form-input option,
select.form-input optgroup{background:var(--raised);color:var(--text)}
.form-hint{font-size:11.5px;color:var(--text-3);margin-top:.25rem}
.form-actions{display:flex;gap:.5rem;margin-top:1.25rem;align-items:center}
/* ── Alerts ──────────────────────────────────────────────────────── */
.alert{display:flex;align-items:flex-start;gap:.5rem;padding:.625rem .875rem;border-radius:6px;font-size:13px;margin-bottom:1rem}
.alert-error{background:rgba(248,113,113,.07);border:1px solid rgba(248,113,113,.2);color:var(--red)}
.alert-warning{background:rgba(245,158,11,.07);border:1px solid rgba(245,158,11,.2);color:var(--amber)}
.alert-info{background:var(--accent-s);border:1px solid rgba(99,102,241,.25);color:#A5B4FC}
/* ── Tables ──────────────────────────────────────────────────────── */
.table-wrap{border:1px solid var(--border);border-radius:8px;overflow:hidden}
table{width:100%;border-collapse:collapse}
thead{background:var(--raised)}
th{padding:.5rem 1rem;text-align:left;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3);border-bottom:1px solid var(--border);white-space:nowrap}
td{padding:.625rem 1rem;font-size:13px;color:var(--text-2);border-bottom:1px solid var(--border)}
tbody tr:last-child td{border-bottom:none}
tbody tr:hover td{background:rgba(255,255,255,.015);color:var(--text)}
.td-name{font-weight:600;color:var(--text) !important}
td code{font-family:var(--mono);font-size:11.5px;background:var(--raised);padding:.1rem .35rem;border-radius:3px}
.empty-row td{text-align:center;padding:2.5rem;color:var(--text-3) !important}
/* ── Badges ──────────────────────────────────────────────────────── */
.badge{display:inline-flex;align-items:center;padding:.15rem .45rem;font-size:11px;font-weight:700;border-radius:4px;text-transform:uppercase;letter-spacing:.04em}
.badge-admin{background:var(--accent-s);color:#A5B4FC;border:1px solid rgba(99,102,241,.2)}
.badge-user{background:var(--raised);color:var(--text-3);border:1px solid var(--border)}
.badge-ok{background:rgba(52,211,153,.08);color:var(--green);border:1px solid rgba(52,211,153,.2)}
.badge-warn{background:rgba(251,191,36,.08);color:#f59e0b;border:1px solid rgba(251,191,36,.2)}
.badge-danger{background:rgba(248,113,113,.08);color:var(--red);border:1px solid rgba(248,113,113,.2)}
/* ── Modals ──────────────────────────────────────────────────────── */
.modal{display:none;position:fixed;inset:0;background:rgba(0,0,0,.6);backdrop-filter:blur(2px);z-index:500;align-items:center;justify-content:center}
.modal.show{display:flex}
.modal-box{background:var(--card);border:1px solid var(--border-2);border-radius:10px;width:90%;max-width:440px;max-height:90vh;overflow-y:auto;animation:pop .12s ease}
@keyframes pop{from{opacity:0;transform:scale(.97) translateY(-4px)}to{opacity:1;transform:none}}
.modal-head{display:flex;justify-content:space-between;align-items:center;padding:.875rem 1.125rem;border-bottom:1px solid var(--border)}
.modal-title{font-size:14px;font-weight:700}
.modal-close{background:none;border:none;color:var(--text-3);cursor:pointer;font-size:1.125rem;line-height:1;padding:.125rem;border-radius:3px;transition:color .1s}
.modal-close:hover{color:var(--text)}
.modal-body{padding:1.125rem}
.donate-modal-box{max-width:660px}
.nav-donate-btn{background:var(--accent);border:none;cursor:pointer;color:#fff;border-radius:6px;font-weight:600;padding:.3rem .75rem;margin-left:.375rem;transition:opacity .15s,transform .1s}
.nav-donate-btn:hover{opacity:.88;transform:scale(1.03)}
.donate-tagline{font-size:13px;color:var(--text-2);margin:0 0 1.25rem;line-height:1.55}
.donate-coins{display:flex;gap:.875rem}
.donate-coin{flex:1;display:flex;flex-direction:column;align-items:center;gap:.75rem;padding:1rem;background:var(--raised);border:1px solid var(--border);border-radius:8px}
.donate-coin-label{font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.07em;color:var(--text-3)}
.donate-qr{display:block;border-radius:5px}
.donate-addr-row{display:flex;align-items:center;gap:.5rem;width:100%;background:var(--card);border:1px solid var(--border);border-radius:5px;padding:.375rem .5rem}
.donate-addr{flex:1;font-family:var(--mono);font-size:9px;color:var(--text-2);word-break:break-all;line-height:1.5;user-select:all}
.donate-paypal-link{color:var(--accent);text-decoration:none}
.donate-paypal-link:hover{text-decoration:underline}
.donate-copy{flex-shrink:0;background:none;border:1px solid var(--border);border-radius:4px;cursor:pointer;padding:.2rem .5rem;font-size:11px;font-weight:600;color:var(--text-3);font-family:var(--font);transition:color .1s,border-color .1s}
.donate-copy:hover{color:var(--text);border-color:var(--border-2)}
@media(max-width:480px){.donate-coins{flex-direction:column}}
/* ── Tabs ────────────────────────────────────────────────────────── */
.tabs{display:flex;gap:1px;border-bottom:1px solid var(--border);margin-bottom:1.25rem}
.tab{padding:.5rem .875rem;font-size:13px;font-weight:500;color:var(--text-2);background:none;border:none;cursor:pointer;border-bottom:2px solid transparent;margin-bottom:-1px;transition:color .1s}
.tab:hover{color:var(--text)}
.tab.active{color:var(--text);border-bottom-color:var(--accent)}
.tab-panel{display:none}
.tab-panel.active{display:block}
/* ── Token box ───────────────────────────────────────────────────── */
.token-box{display:flex;align-items:center;gap:.625rem;padding:.625rem .875rem;background:var(--raised);border:1px solid var(--border);border-radius:6px;margin:.875rem 0}
.token-box code{flex:1;font-family:var(--mono);font-size:11.5px;color:var(--accent);word-break:break-all}
/* ── Chat ────────────────────────────────────────────────────────── */
.chat-wrap{display:flex;flex-direction:column;height:calc(100vh - 44px - 2rem);background:var(--card);border:1px solid var(--border);border-radius:8px;overflow:hidden}
.chat-bar{display:flex;justify-content:space-between;align-items:center;padding:.625rem 1rem;border-bottom:1px solid var(--border);flex-shrink:0;gap:.75rem}
.chat-bar h2{font-size:13.5px;font-weight:700}
.chat-controls{display:flex;gap:.5rem;align-items:center}
.chat-messages{flex:1;overflow-y:auto;padding:1.125rem;scroll-behavior:smooth}
.chat-empty{text-align:center;padding:4rem 1rem;color:var(--text-3)}
.chat-empty h3{font-size:1rem;font-weight:600;color:var(--text-2);margin-bottom:.35rem}
.msg{display:flex;gap:.625rem;margin-bottom:1rem}
.msg-av{width:24px;height:24px;border-radius:5px;display:flex;align-items:center;justify-content:center;font-size:9px;font-weight:700;flex-shrink:0;margin-top:1px;font-family:var(--mono)}
.msg-av.user{background:rgba(99,102,241,.15);color:var(--accent);border:1px solid rgba(99,102,241,.2)}
.msg-av.ai{background:var(--raised);color:var(--text-2);border:1px solid var(--border)}
.msg-body{flex:1}
.msg-meta{font-size:11px;color:var(--text-3);margin-bottom:.25rem;font-family:var(--mono)}
.msg-text{background:var(--raised);border:1px solid var(--border);border-radius:6px;padding:.5rem .75rem;font-size:13.5px;line-height:1.6;color:var(--text);word-wrap:break-word}
.msg.user .msg-text{background:rgba(99,102,241,.06);border-color:rgba(99,102,241,.15)}
.chat-foot{padding:.75rem 1rem;border-top:1px solid var(--border);flex-shrink:0}
.chat-input-row{display:flex;gap:.5rem;align-items:flex-end}
.chat-textarea{flex:1;padding:.5rem .75rem;background:var(--raised);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13.5px;resize:none;min-height:38px;max-height:140px;line-height:1.5;transition:border-color .1s}
.chat-textarea:focus{outline:none;border-color:var(--accent)}
.chat-hint{font-size:11px;color:var(--text-3);margin-top:.375rem}
/* ── Login ───────────────────────────────────────────────────────── */
.login-wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;background:var(--bg);padding:1.5rem}
.login-card{width:100%;max-width:360px;background:var(--card);border:1px solid var(--border-2);border-radius:10px;padding:2rem}
.login-logo{display:flex;align-items:center;gap:.625rem;margin-bottom:1.75rem}
.login-mark{width:30px;height:30px;background:var(--accent);border-radius:7px;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:#fff}
.login-logo-text h1{font-size:1.0625rem;font-weight:700;letter-spacing:-.01em}
.login-logo-text p{font-size:11.5px;color:var(--text-2)}
.login-footer{margin-top:1.25rem;text-align:center;font-size:11.5px;color:var(--text-3);font-family:var(--mono)}
/* ── Centered form (change pw, etc.) ─────────────────────────────── */
.centered-wrap{min-height:calc(100vh - 44px);display:flex;align-items:center;justify-content:center;padding:1.5rem}
.centered-card{width:100%;max-width:400px;background:var(--card);border:1px solid var(--border);border-radius:10px;padding:1.75rem}
.centered-card h1{font-size:1.0625rem;font-weight:700;margin-bottom:.375rem}
.centered-card .sub{font-size:12.5px;color:var(--text-2);margin-bottom:1.5rem}
/* ── Search bar ──────────────────────────────────────────────────── */
.search-bar{display:flex;gap:.5rem;margin-bottom:1rem}
.search-bar .form-input{flex:1}
/* ── Divider ─────────────────────────────────────────────────────── */
hr{border:none;border-top:1px solid var(--border);margin:1.125rem 0}
/* ── Utils ───────────────────────────────────────────────────────── */
.mono{font-family:var(--mono)}
.muted{color:var(--text-3)}
.dim{color:var(--text-2)}
.small{font-size:12.5px}
.text-green{color:var(--green)}
.text-red{color:var(--red)}
.text-amber{color:var(--amber)}
.flex{display:flex}.items-center{align-items:center}.gap-2{gap:.5rem}.mb-0{margin-bottom:0!important}
/* ── Responsive ──────────────────────────────────────────────────── */
@media(max-width:640px){
.topnav-inner{padding:0 1rem}
.nav-links{gap:0}
.nav-link{padding:.3rem .5rem;font-size:12.5px}
.container{padding:1.25rem 1rem}
}
...@@ -44,6 +44,20 @@ _download_sessions: dict = {} ...@@ -44,6 +44,20 @@ _download_sessions: dict = {}
_download_status: dict = {} # session_id → latest progress state (survives SSE disconnect) _download_status: dict = {} # session_id → latest progress state (survives SSE disconnect)
def _url(request: Request, path: str) -> str:
"""Return a proxy-aware absolute path (root_path prefix + path)."""
from codai.api.urlutils import get_public_prefix
return get_public_prefix(request) + path
def _tmpl(request: Request, name: str, ctx: dict = None):
"""Render a template with root_path injected into the context."""
from codai.api.urlutils import get_public_prefix
c = ctx or {}
c.setdefault("root_path", get_public_prefix(request))
return templates.TemplateResponse(request, name, c)
def init_session_manager(config_dir: Path): def init_session_manager(config_dir: Path):
"""Initialize the session manager.""" """Initialize the session manager."""
global session_manager global session_manager
...@@ -117,9 +131,9 @@ async def login_page(request: Request): ...@@ -117,9 +131,9 @@ async def login_page(request: Request):
# If already logged in, redirect to dashboard # If already logged in, redirect to dashboard
username = get_current_user(request) username = get_current_user(request)
if username: if username:
return RedirectResponse(url="/admin", status_code=302) return RedirectResponse(url=_url(request, "/admin"), status_code=302)
return templates.TemplateResponse(request, "login.html", {"error": None}) return _tmpl(request, "login.html", {"error": None})
@router.post("/login") @router.post("/login")
...@@ -135,17 +149,15 @@ async def login( ...@@ -135,17 +149,15 @@ async def login(
session_cookie = session_manager.authenticate(username, password) session_cookie = session_manager.authenticate(username, password)
if not session_cookie: if not session_cookie:
return templates.TemplateResponse(request, "login.html", {"error": "Invalid username or password"}) return _tmpl(request, "login.html", {"error": "Invalid username or password"})
# Check if must change password # Check if must change password
must_change = session_cookie.endswith(".MUST_CHANGE") must_change = session_cookie.endswith(".MUST_CHANGE")
if must_change: if must_change:
session_cookie = session_cookie[:-12] session_cookie = session_cookie[:-12]
response = RedirectResponse( redirect_path = "/admin/change-password" if must_change else "/admin"
url="/admin/change-password" if must_change else "/admin", response = RedirectResponse(url=_url(request, redirect_path), status_code=302)
status_code=302
)
response.set_cookie( response.set_cookie(
key="session", key="session",
value=session_cookie, value=session_cookie,
...@@ -163,8 +175,8 @@ async def logout(request: Request): ...@@ -163,8 +175,8 @@ async def logout(request: Request):
if session_manager: if session_manager:
cookie = request.cookies.get("session") cookie = request.cookies.get("session")
session_manager.destroy_session(cookie) session_manager.destroy_session(cookie)
response = RedirectResponse(url="/login", status_code=302) response = RedirectResponse(url=_url(request, "/login"), status_code=302)
response.delete_cookie("session") response.delete_cookie("session")
return response return response
...@@ -173,7 +185,7 @@ async def logout(request: Request): ...@@ -173,7 +185,7 @@ async def logout(request: Request):
async def change_password_page(request: Request, username: str = Depends(require_auth)): async def change_password_page(request: Request, username: str = Depends(require_auth)):
user = session_manager.get_user(username) user = session_manager.get_user(username)
must_change = user.get("must_change_password", False) if user else False must_change = user.get("must_change_password", False) if user else False
return templates.TemplateResponse(request, "change_password.html", { return _tmpl(request, "change_password.html", {
"username": username, "username": username,
"must_change": must_change, "must_change": must_change,
"is_admin": session_manager.is_admin(username), "is_admin": session_manager.is_admin(username),
...@@ -194,7 +206,7 @@ async def change_password( ...@@ -194,7 +206,7 @@ async def change_password(
must_change = user.get("must_change_password", False) if user else False must_change = user.get("must_change_password", False) if user else False
def render_error(msg: str): def render_error(msg: str):
return templates.TemplateResponse(request, "change_password.html", { return _tmpl(request, "change_password.html", {
"username": username, "must_change": must_change, "username": username, "must_change": must_change,
"is_admin": is_admin, "error": msg, "is_admin": is_admin, "error": msg,
}) })
...@@ -214,38 +226,38 @@ async def change_password( ...@@ -214,38 +226,38 @@ async def change_password(
if not success: if not success:
return render_error("Current password is incorrect") return render_error("Current password is incorrect")
return RedirectResponse(url="/admin", status_code=302) return RedirectResponse(url=_url(request, "/admin"), status_code=302)
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, username: str = Depends(require_auth)): async def admin_dashboard(request: Request, username: str = Depends(require_auth)):
is_admin = session_manager.is_admin(username) is_admin = session_manager.is_admin(username)
return templates.TemplateResponse(request, "dashboard.html", { return _tmpl(request, "dashboard.html", {
"username": username, "is_admin": is_admin, "username": username, "is_admin": is_admin,
}) })
@router.get("/admin/models", response_class=HTMLResponse) @router.get("/admin/models", response_class=HTMLResponse)
async def models_page(request: Request, username: str = Depends(require_admin)): async def models_page(request: Request, username: str = Depends(require_admin)):
return templates.TemplateResponse(request, "models.html", {"username": username, "is_admin": True}) return _tmpl(request, "models.html", {"username": username, "is_admin": True})
@router.get("/admin/tokens", response_class=HTMLResponse) @router.get("/admin/tokens", response_class=HTMLResponse)
async def tokens_page(request: Request, username: str = Depends(require_admin)): async def tokens_page(request: Request, username: str = Depends(require_admin)):
return templates.TemplateResponse(request, "tokens.html", {"username": username, "is_admin": True}) return _tmpl(request, "tokens.html", {"username": username, "is_admin": True})
@router.get("/admin/users", response_class=HTMLResponse) @router.get("/admin/users", response_class=HTMLResponse)
async def users_page(request: Request, username: str = Depends(require_admin)): async def users_page(request: Request, username: str = Depends(require_admin)):
users = session_manager.list_users() users = session_manager.list_users()
return templates.TemplateResponse(request, "users.html", { return _tmpl(request, "users.html", {
"username": username, "is_admin": True, "users": users, "username": username, "is_admin": True, "users": users,
}) })
@router.get("/chat", response_class=HTMLResponse) @router.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request, username: str = Depends(require_auth)): async def chat_page(request: Request, username: str = Depends(require_auth)):
return templates.TemplateResponse(request, "chat.html", { return _tmpl(request, "chat.html", {
"username": username, "is_admin": session_manager.is_admin(username), "username": username, "is_admin": session_manager.is_admin(username),
}) })
...@@ -347,7 +359,7 @@ async def api_status(username: str = Depends(require_auth)): ...@@ -347,7 +359,7 @@ async def api_status(username: str = Depends(require_auth)):
if config_manager: if config_manager:
md = config_manager.models_data md = config_manager.models_data
for cat in ("text_models", "image_models", "audio_models", "vision_models", "tts_models", for cat in ("text_models", "image_models", "audio_models", "vision_models", "tts_models",
"video_models", "audio_gen_models", "embedding_models"): "video_models", "audio_gen_models", "embedding_models", "spatial_models"):
for m in md.get(cat, []): for m in md.get(cat, []):
if isinstance(m, dict): if isinstance(m, dict):
mid = m.get("path") or m.get("id") or "" mid = m.get("path") or m.get("id") or ""
...@@ -872,7 +884,7 @@ def _scan_caches() -> dict: ...@@ -872,7 +884,7 @@ def _scan_caches() -> dict:
md = config_manager.models_data md = config_manager.models_data
for cat in ("text_models", "image_models", "audio_models", for cat in ("text_models", "image_models", "audio_models",
"gguf_models", "tts_models", "vision_models", "video_models", "gguf_models", "tts_models", "vision_models", "video_models",
"audio_gen_models", "embedding_models"): "audio_gen_models", "embedding_models", "spatial_models"):
for m in md.get(cat, []): for m in md.get(cat, []):
if isinstance(m, str): if isinstance(m, str):
p = m p = m
...@@ -906,7 +918,9 @@ def _scan_caches() -> dict: ...@@ -906,7 +918,9 @@ def _scan_caches() -> dict:
cfg = (configured_settings.get(fpath) cfg = (configured_settings.get(fpath)
or configured_settings.get(fname) or configured_settings.get(fname)
or ({}, None)) or ({}, None))
caps = detect_model_capabilities(fname) cfg_s = cfg[0] if isinstance(cfg[0], dict) else {}
saved_caps = cfg_s.get("capabilities") or []
caps_list = saved_caps if saved_caps else detect_model_capabilities(fname).to_list()
result["gguf"].append({ result["gguf"].append({
"filename": fname, "filename": fname,
"path": fpath, "path": fpath,
...@@ -914,15 +928,21 @@ def _scan_caches() -> dict: ...@@ -914,15 +928,21 @@ def _scan_caches() -> dict:
"size_bytes": fsize, "size_bytes": fsize,
"in_config": fpath in configured_settings or fname in configured_settings, "in_config": fpath in configured_settings or fname in configured_settings,
"model_type": cfg[1] if cfg[1] and cfg[1] != "gguf_models" else "text_models", "model_type": cfg[1] if cfg[1] and cfg[1] != "gguf_models" else "text_models",
"settings": cfg[0] if isinstance(cfg[0], dict) else {}, "settings": cfg_s,
"capabilities": caps.to_list(), "capabilities": caps_list,
"source_repo": repo.repo_id, "source_repo": repo.repo_id,
}) })
continue # skip adding to hf list continue # skip adding to hf list
cfg = configured_settings.get(repo.repo_id, ({}, None)) cfg = configured_settings.get(repo.repo_id, ({}, None))
caps = (lookup_capability_cache(repo.repo_id) cfg_settings = cfg[0] if isinstance(cfg[0], dict) else {}
or detect_model_capabilities(repo.repo_id)) saved_caps = cfg_settings.get("capabilities") or []
if saved_caps:
caps_list = saved_caps
else:
caps = (lookup_capability_cache(repo.repo_id)
or detect_model_capabilities(repo.repo_id))
caps_list = caps.to_list()
result["hf"].append({ result["hf"].append({
"id": repo.repo_id, "id": repo.repo_id,
"size_gb": round(size_bytes / 1e9, 2), "size_gb": round(size_bytes / 1e9, 2),
...@@ -932,8 +952,8 @@ def _scan_caches() -> dict: ...@@ -932,8 +952,8 @@ def _scan_caches() -> dict:
"file_count": len(files), "file_count": len(files),
"in_config": repo.repo_id in configured_settings, "in_config": repo.repo_id in configured_settings,
"model_type": cfg[1] if cfg[1] and cfg[1] != "gguf_models" else "text_models", "model_type": cfg[1] if cfg[1] and cfg[1] != "gguf_models" else "text_models",
"settings": cfg[0] if isinstance(cfg[0], dict) else {}, "settings": cfg_settings,
"capabilities": caps.to_list(), "capabilities": caps_list,
}) })
except Exception as e: except Exception as e:
result["hf_error"] = str(e) result["hf_error"] = str(e)
...@@ -948,7 +968,9 @@ def _scan_caches() -> dict: ...@@ -948,7 +968,9 @@ def _scan_caches() -> dict:
cfg = (configured_settings.get(fpath) cfg = (configured_settings.get(fpath)
or configured_settings.get(fname) or configured_settings.get(fname)
or ({}, None)) or ({}, None))
caps = detect_model_capabilities(fname) cfg_s = cfg[0] if isinstance(cfg[0], dict) else {}
saved_caps = cfg_s.get("capabilities") or []
caps_list = saved_caps if saved_caps else detect_model_capabilities(fname).to_list()
result["gguf"].append({ result["gguf"].append({
"filename": fname, "filename": fname,
"path": fpath, "path": fpath,
...@@ -956,8 +978,8 @@ def _scan_caches() -> dict: ...@@ -956,8 +978,8 @@ def _scan_caches() -> dict:
"size_bytes": size, "size_bytes": size,
"in_config": fpath in configured_settings or fname in configured_settings, "in_config": fpath in configured_settings or fname in configured_settings,
"model_type": cfg[1] if cfg[1] and cfg[1] != "gguf_models" else "text_models", "model_type": cfg[1] if cfg[1] and cfg[1] != "gguf_models" else "text_models",
"settings": cfg[0] if isinstance(cfg[0], dict) else {}, "settings": cfg_s,
"capabilities": caps.to_list(), "capabilities": caps_list,
}) })
# Add configured GGUF models not yet in the list (e.g., HF repo IDs or external paths) # Add configured GGUF models not yet in the list (e.g., HF repo IDs or external paths)
...@@ -1190,7 +1212,7 @@ async def api_model_enable(request: Request, username: str = Depends(require_adm ...@@ -1190,7 +1212,7 @@ async def api_model_enable(request: Request, username: str = Depends(require_adm
path = data.get("path") or data.get("model_id", "") path = data.get("path") or data.get("model_id", "")
model_type = data.get("model_type", "text_models") model_type = data.get("model_type", "text_models")
valid = {"text_models", "image_models", "audio_models", "gguf_models", "tts_models", "vision_models", valid = {"text_models", "image_models", "audio_models", "gguf_models", "tts_models", "vision_models",
"video_models", "audio_gen_models", "embedding_models"} "video_models", "audio_gen_models", "embedding_models", "spatial_models"}
if model_type not in valid: if model_type not in valid:
raise HTTPException(status_code=400, detail=f"model_type must be one of {valid}") raise HTTPException(status_code=400, detail=f"model_type must be one of {valid}")
lst = config_manager.models_data.setdefault(model_type, []) lst = config_manager.models_data.setdefault(model_type, [])
...@@ -1218,7 +1240,7 @@ async def api_model_disable(request: Request, username: str = Depends(require_ad ...@@ -1218,7 +1240,7 @@ async def api_model_disable(request: Request, username: str = Depends(require_ad
changed = False changed = False
for cat in ("text_models", "image_models", "audio_models", for cat in ("text_models", "image_models", "audio_models",
"gguf_models", "tts_models", "vision_models", "video_models", "gguf_models", "tts_models", "vision_models", "video_models",
"audio_gen_models", "embedding_models"): "audio_gen_models", "embedding_models", "spatial_models"):
lst = config_manager.models_data.get(cat, []) lst = config_manager.models_data.get(cat, [])
new_lst = [m for m in lst if not _matches(m)] new_lst = [m for m in lst if not _matches(m)]
if len(new_lst) != len(lst): if len(new_lst) != len(lst):
...@@ -1242,7 +1264,8 @@ async def api_model_loaded_status(username: str = Depends(require_admin)): ...@@ -1242,7 +1264,8 @@ async def api_model_loaded_status(username: str = Depends(require_admin)):
configured_max = {} configured_max = {}
if config_manager: if config_manager:
for cat in ("text_models", "image_models", "audio_models", "vision_models", for cat in ("text_models", "image_models", "audio_models", "vision_models",
"tts_models", "gguf_models", "video_models", "audio_gen_models", "embedding_models"): "tts_models", "gguf_models", "video_models", "audio_gen_models",
"embedding_models", "spatial_models"):
for m in config_manager.models_data.get(cat, []): for m in config_manager.models_data.get(cat, []):
if isinstance(m, dict): if isinstance(m, dict):
path = m.get("path") or m.get("id") or "" path = m.get("path") or m.get("id") or ""
...@@ -1270,7 +1293,8 @@ async def api_model_load(request: Request, username: str = Depends(require_admin ...@@ -1270,7 +1293,8 @@ async def api_model_load(request: Request, username: str = Depends(require_admin
("vision_models", "vision"), ("tts_models", "tts"), ("vision_models", "vision"), ("tts_models", "tts"),
("video_models", "video"), ("video_models", "video"),
("audio_gen_models", "audio_gen"), ("audio_gen_models", "audio_gen"),
("embedding_models", "embedding")): ("embedding_models", "embedding"),
("spatial_models", "spatial")):
for m in md.get(cat, []): for m in md.get(cat, []):
mid = m if isinstance(m, str) else m.get("path") or m.get("id") or "" mid = m if isinstance(m, str) else m.get("path") or m.get("id") or ""
if mid == path: if mid == path:
...@@ -1440,7 +1464,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -1440,7 +1464,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
return result return result
path = data.get("path") or data.get("model_id", "") path = data.get("path") or data.get("model_id", "")
valid = {"text_models", "image_models", "audio_models", "tts_models", "vision_models", "video_models", valid = {"text_models", "image_models", "audio_models", "tts_models", "vision_models", "video_models",
"audio_gen_models", "embedding_models"} "audio_gen_models", "embedding_models", "spatial_models"}
if not path: if not path:
raise HTTPException(status_code=400, detail="path is required") raise HTTPException(status_code=400, detail="path is required")
...@@ -1501,7 +1525,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_ ...@@ -1501,7 +1525,7 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"max_gpu_percent", "manual_ram_gb", "load_in_4bit", "load_in_8bit", "max_gpu_percent", "manual_ram_gb", "load_in_4bit", "load_in_8bit",
"flash_attention", "no_ram", "offload_strategy", "offload_dir", "flash_attention", "no_ram", "offload_strategy", "offload_dir",
"system_prompt", "parser", "tools_closer_prompt", "grammar_guided", "system_prompt", "parser", "tools_closer_prompt", "grammar_guided",
"max_instances", "preload_all_instances"): "max_instances", "preload_all_instances", "capabilities"):
if key in data: if key in data:
entry[key] = data[key] entry[key] = data[key]
...@@ -1542,7 +1566,7 @@ from datetime import datetime ...@@ -1542,7 +1566,7 @@ from datetime import datetime
@router.get("/admin/settings", response_class=HTMLResponse) @router.get("/admin/settings", response_class=HTMLResponse)
async def settings_page(request: Request, username: str = Depends(require_admin)): async def settings_page(request: Request, username: str = Depends(require_admin)):
return templates.TemplateResponse(request, "settings.html", {"username": username, "is_admin": True}) return _tmpl(request, "settings.html", {"username": username, "is_admin": True})
@router.get("/admin/api/settings") @router.get("/admin/api/settings")
...@@ -1693,7 +1717,7 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad ...@@ -1693,7 +1717,7 @@ async def api_save_settings(request: Request, username: str = Depends(require_ad
@router.get("/admin/archive", response_class=HTMLResponse) @router.get("/admin/archive", response_class=HTMLResponse)
async def archive_page(request: Request, username: str = Depends(require_admin)): async def archive_page(request: Request, username: str = Depends(require_admin)):
return templates.TemplateResponse(request, "archive.html", {"username": username, "is_admin": True}) return _tmpl(request, "archive.html", {"username": username, "is_admin": True})
@router.get("/admin/api/archive") @router.get("/admin/api/archive")
......
...@@ -270,7 +270,7 @@ function openDetail(entry) { ...@@ -270,7 +270,7 @@ function openDetail(entry) {
} else if (entry.type === 'audio' || entry.type === 'tts') { } else if (entry.type === 'audio' || entry.type === 'tts') {
const firstAudio = (entry.files || []).find(f => /\.(wav|mp3|ogg|flac)$/.test(f)); const firstAudio = (entry.files || []).find(f => /\.(wav|mp3|ogg|flac)$/.test(f));
if (firstAudio) { if (firstAudio) {
previewHtml = `<audio controls style="width:100%"><source src="/admin/api/archive/${entry.id}/files/${firstAudio}"></audio>`; previewHtml = `<audio controls style="width:100%"><source src="${ROOT_PATH}/admin/api/archive/${entry.id}/files/${firstAudio}"></audio>`;
} else { } else {
previewHtml = `<div style="font-size:3rem;padding:2rem;text-align:center">${TYPE_ICONS[entry.type]}</div>`; previewHtml = `<div style="font-size:3rem;padding:2rem;text-align:center">${TYPE_ICONS[entry.type]}</div>`;
} }
...@@ -302,7 +302,7 @@ function openDetail(entry) { ...@@ -302,7 +302,7 @@ function openDetail(entry) {
<div class="param-list">${paramChips}</div>` : ''} <div class="param-list">${paramChips}</div>` : ''}
${entry.files?.length > 1 ? `<div style="margin:.75rem 0 .25rem;font-size:12px;font-weight:600;color:var(--text-muted)">Files</div> ${entry.files?.length > 1 ? `<div style="margin:.75rem 0 .25rem;font-size:12px;font-weight:600;color:var(--text-muted)">Files</div>
<div style="display:flex;flex-direction:column;gap:.3rem">${entry.files.map(f => <div style="display:flex;flex-direction:column;gap:.3rem">${entry.files.map(f =>
`<a href="/admin/api/archive/${entry.id}/files/${f}" target="_blank" style="font-size:12px;color:var(--accent)">${f}</a>` `<a href="${ROOT_PATH}/admin/api/archive/${entry.id}/files/${f}" target="_blank" style="font-size:12px;color:var(--accent)">${f}</a>`
).join('')}</div>` : ''} ).join('')}</div>` : ''}
</div> </div>
</div>`; </div>`;
...@@ -342,7 +342,7 @@ async function deleteEntry() { ...@@ -342,7 +342,7 @@ async function deleteEntry() {
// Settings // Settings
async function loadArchiveSettings() { async function loadArchiveSettings() {
try { try {
const d = await fetch('/admin/api/archive-settings').then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/archive-settings').then(r=>r.json());
document.getElementById('arc-dir').value = d.directory || ''; document.getElementById('arc-dir').value = d.directory || '';
document.getElementById('arc-dir').placeholder = d.default_directory || '<config_dir>/archive'; document.getElementById('arc-dir').placeholder = d.default_directory || '<config_dir>/archive';
document.getElementById('arc-dir-hint').textContent = `Default: ${d.default_directory || '<config_dir>/archive'}`; document.getElementById('arc-dir-hint').textContent = `Default: ${d.default_directory || '<config_dir>/archive'}`;
...@@ -361,7 +361,7 @@ async function saveArchiveSettings() { ...@@ -361,7 +361,7 @@ async function saveArchiveSettings() {
retention: document.getElementById('arc-retention').value, retention: document.getElementById('arc-retention').value,
} }
}; };
const r = await fetch('/admin/api/settings', { const r = await fetch(ROOT_PATH + '/admin/api/settings', {
method: 'POST', method: 'POST',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload), body: JSON.stringify(payload),
......
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/admin/style.css"> <link rel="stylesheet" href="{{ root_path }}/static/admin/style.css">
<script>const ROOT_PATH = "{{ root_path }}";</script>
{% block head %}{% endblock %} {% block head %}{% endblock %}
</head> </head>
<body> <body>
...@@ -16,20 +17,20 @@ ...@@ -16,20 +17,20 @@
<nav class="topnav"> <nav class="topnav">
<div class="topnav-inner"> <div class="topnav-inner">
<div class="topnav-left"> <div class="topnav-left">
<a href="/admin" class="nav-logo"> <a href="{{ root_path }}/admin" class="nav-logo">
<div class="nav-logo-mark">AI</div> <div class="nav-logo-mark">AI</div>
<span class="nav-logo-name">CoderAI</span> <span class="nav-logo-name">CoderAI</span>
</a> </a>
<div class="nav-links"> <div class="nav-links">
<a href="/admin" class="nav-link {% if request.url.path == '/admin' %}active{% endif %}">Overview</a> <a href="{{ root_path }}/admin" class="nav-link {% if request.url.path == '/admin' %}active{% endif %}">Overview</a>
<a href="/chat" class="nav-link {% if request.url.path == '/chat' %}active{% endif %}">Studio</a> <a href="{{ root_path }}/chat" class="nav-link {% if request.url.path == '/chat' %}active{% endif %}">Studio</a>
<a href="/docs" class="nav-link" target="_blank">API Docs</a> <a href="{{ root_path }}/docs" class="nav-link" target="_blank">API Docs</a>
{% if is_admin|default(false) %} {% if is_admin|default(false) %}
<a href="/admin/models" class="nav-link {% if '/models' in request.url.path %}active{% endif %}">Models</a> <a href="{{ root_path }}/admin/models" class="nav-link {% if '/models' in request.url.path %}active{% endif %}">Models</a>
<a href="/admin/tokens" class="nav-link {% if '/tokens' in request.url.path %}active{% endif %}">Tokens</a> <a href="{{ root_path }}/admin/tokens" class="nav-link {% if '/tokens' in request.url.path %}active{% endif %}">Tokens</a>
<a href="/admin/users" class="nav-link {% if '/users' in request.url.path %}active{% endif %}">Users</a> <a href="{{ root_path }}/admin/users" class="nav-link {% if '/users' in request.url.path %}active{% endif %}">Users</a>
<a href="/admin/archive" class="nav-link {% if '/archive' in request.url.path %}active{% endif %}">Archive</a> <a href="{{ root_path }}/admin/archive" class="nav-link {% if '/archive' in request.url.path %}active{% endif %}">Archive</a>
<a href="/admin/settings" class="nav-link {% if '/settings' in request.url.path %}active{% endif %}">Settings</a> <a href="{{ root_path }}/admin/settings" class="nav-link {% if '/settings' in request.url.path %}active{% endif %}">Settings</a>
{% endif %} {% endif %}
<button class="nav-link nav-donate-btn" onclick="document.getElementById('donateModal').classList.add('show')">&#9829; Donate</button> <button class="nav-link nav-donate-btn" onclick="document.getElementById('donateModal').classList.add('show')">&#9829; Donate</button>
</div> </div>
...@@ -37,7 +38,7 @@ ...@@ -37,7 +38,7 @@
<div class="topnav-right"> <div class="topnav-right">
<span class="nav-username">{{ username }}</span> <span class="nav-username">{{ username }}</span>
<div class="nav-sep"></div> <div class="nav-sep"></div>
<a href="/logout" class="nav-logout">Sign out</a> <a href="{{ root_path }}/logout" class="nav-logout">Sign out</a>
</div> </div>
</div> </div>
</nav> </nav>
......
...@@ -14,7 +14,7 @@ ...@@ -14,7 +14,7 @@
<div class="alert alert-error">{{ error }}</div> <div class="alert alert-error">{{ error }}</div>
{% endif %} {% endif %}
<form method="post" action="/admin/change-password"> <form method="post" action="{{ root_path }}/admin/change-password">
{% if not must_change %} {% if not must_change %}
<div class="form-row"> <div class="form-row">
<label class="form-label" for="old_password">Current Password</label> <label class="form-label" for="old_password">Current Password</label>
...@@ -36,7 +36,7 @@ ...@@ -36,7 +36,7 @@
<div class="form-actions"> <div class="form-actions">
<button type="submit" class="btn btn-primary">Update password</button> <button type="submit" class="btn btn-primary">Update password</button>
{% if not must_change %} {% if not must_change %}
<a href="/admin" class="btn btn-ghost">Cancel</a> <a href="{{ root_path }}/admin" class="btn btn-ghost">Cancel</a>
{% endif %} {% endif %}
</div> </div>
</form> </form>
......
...@@ -387,11 +387,10 @@ a.dl { display:inline-block; margin-top:.4rem; } ...@@ -387,11 +387,10 @@ a.dl { display:inline-block; margin-top:.4rem; }
<button class="t1btn" data-cat="image" onclick="selectCat('image')">Image <span class="tab-status" hidden></span></button> <button class="t1btn" data-cat="image" onclick="selectCat('image')">Image <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="video" onclick="selectCat('video')">Video <span class="tab-status" hidden></span></button> <button class="t1btn" data-cat="video" onclick="selectCat('video')">Video <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="audio" onclick="selectCat('audio')">Audio <span class="tab-status" hidden></span></button> <button class="t1btn" data-cat="audio" onclick="selectCat('audio')">Audio <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="pipe" onclick="selectCat('pipe')">Pipelines <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="embed" onclick="selectCat('embed')">Embed <span class="tab-status" hidden></span></button> <button class="t1btn" data-cat="embed" onclick="selectCat('embed')">Embed <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="archive" onclick="selectCat('archive')">Archive <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="3d" onclick="selectCat('3d')">3D <span class="tab-status" hidden></span></button> <button class="t1btn" data-cat="3d" onclick="selectCat('3d')">3D <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="profiles" onclick="selectCat('profiles')">Profiles <span class="tab-status" hidden></span></button> <button class="t1btn" data-cat="profiles" onclick="selectCat('profiles')">Profiles <span class="tab-status" hidden></span></button>
<button class="t1btn" data-cat="pipe" onclick="selectCat('pipe')">Pipelines <span class="tab-status" hidden></span></button>
</div> </div>
<!-- Level-2 tabs (sub-mode) --> <!-- Level-2 tabs (sub-mode) -->
...@@ -615,6 +614,7 @@ a.dl { display:inline-block; margin-top:.4rem; } ...@@ -615,6 +614,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="gen-wrap"> <div class="gen-wrap">
<div class="gen-ctrl"> <div class="gen-ctrl">
<div class="cap-card" id="cap-img-faceswap"></div> <div class="cap-card" id="cap-img-faceswap"></div>
<div class="cap-card" id="cap-vid-faceswap" style="display:none"></div>
<div class="frow"><label class="fl">Source face</label> <div class="frow"><label class="fl">Source face</label>
<div class="media-input-group"> <div class="media-input-group">
<input type="file" id="fs-src" accept="image/*" class="fi" onchange="previewMediaInput('fs-src','fs-src-preview')"> <input type="file" id="fs-src" accept="image/*" class="fi" onchange="previewMediaInput('fs-src','fs-src-preview')">
...@@ -685,6 +685,7 @@ a.dl { display:inline-block; margin-top:.4rem; } ...@@ -685,6 +685,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="gen-wrap"> <div class="gen-wrap">
<div class="gen-ctrl"> <div class="gen-ctrl">
<div class="cap-card" id="cap-img-outfit"></div> <div class="cap-card" id="cap-img-outfit"></div>
<div class="cap-card" id="cap-vid-outfit" style="display:none"></div>
<div class="frow"><label class="fl">Target type</label> <div class="frow"><label class="fl">Target type</label>
<select id="ot-type" class="fselect" onchange="otOutfitTypeChange()"> <select id="ot-type" class="fselect" onchange="otOutfitTypeChange()">
<option value="image">Image</option> <option value="image">Image</option>
...@@ -1723,6 +1724,7 @@ a.dl { display:inline-block; margin-top:.4rem; } ...@@ -1723,6 +1724,7 @@ a.dl { display:inline-block; margin-top:.4rem; }
<div class="panel" id="panel-embed"> <div class="panel" id="panel-embed">
<div class="gen-wrap"> <div class="gen-wrap">
<div class="gen-ctrl"> <div class="gen-ctrl">
<div class="cap-card" id="cap-embed"></div>
<div class="frow"><label class="fl">Text(s) — one per line</label><textarea id="em-text" class="fs" rows="6" placeholder="Enter text to embed…"></textarea></div> <div class="frow"><label class="fl">Text(s) — one per line</label><textarea id="em-text" class="fs" rows="6" placeholder="Enter text to embed…"></textarea></div>
<div class="frow"><label class="fl">Encoding</label> <div class="frow"><label class="fl">Encoding</label>
<select id="em-enc" class="fselect"><option value="float">Float array</option><option value="base64">Base64</option></select> <select id="em-enc" class="fselect"><option value="float">Float array</option><option value="base64">Base64</option></select>
...@@ -2099,7 +2101,7 @@ function _startVidPoll(prefix) { ...@@ -2099,7 +2101,7 @@ function _startVidPoll(prefix) {
wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent=''; wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent='';
_vidPollTimer = setInterval(async () => { _vidPollTimer = setInterval(async () => {
try { try {
const p = await (await fetch('/v1/video/progress')).json(); const p = await (await fetch(ROOT_PATH + '/v1/video/progress')).json();
if (p.total > 0) { if (p.total > 0) {
fill.style.width = p.pct + '%'; fill.style.width = p.pct + '%';
const spd = p.it_per_s > 0 ? ` · ${p.it_per_s} it/s` : (p.elapsed > 0 ? ` · ${p.elapsed}s` : ''); const spd = p.it_per_s > 0 ? ` · ${p.it_per_s} it/s` : (p.elapsed > 0 ? ` · ${p.elapsed}s` : '');
...@@ -2127,7 +2129,7 @@ function _startAudPoll(prefix) { ...@@ -2127,7 +2129,7 @@ function _startAudPoll(prefix) {
wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent=''; wrap.classList.add('active'); fill.style.width='0%'; lbl.textContent='';
_audPollTimer = setInterval(async () => { _audPollTimer = setInterval(async () => {
try { try {
const p = await (await fetch('/v1/audio/progress')).json(); const p = await (await fetch(ROOT_PATH + '/v1/audio/progress')).json();
if (p.total > 0) { if (p.total > 0) {
fill.style.width = p.pct + '%'; fill.style.width = p.pct + '%';
const unit = p.unit || 'it'; const unit = p.unit || 'it';
...@@ -2273,7 +2275,7 @@ const STUDIO_CAPABILITIES = { ...@@ -2273,7 +2275,7 @@ const STUDIO_CAPABILITIES = {
'This endpoint is runnable, but it performs best-effort estimation rather than ML source separation.', 'This endpoint is runnable, but it performs best-effort estimation rather than ML source separation.',
'Center-panned vocals separate more cleanly than dense or heavily stereo-processed mixes.' 'Center-panned vocals separate more cleanly than dense or heavily stereo-processed mixes.'
], ],
backendPath:'/v1/audio/stems', backendPath: ROOT_PATH + '/v1/audio/stems',
io:'Input: mixed audio. Output: downloadable stem estimate artifacts.' io:'Input: mixed audio. Output: downloadable stem estimate artifacts.'
}, },
'aud-clean': { 'aud-clean': {
...@@ -2286,8 +2288,75 @@ const STUDIO_CAPABILITIES = { ...@@ -2286,8 +2288,75 @@ const STUDIO_CAPABILITIES = {
'This endpoint is runnable, but it uses standard filter processing rather than deep restoration models.', 'This endpoint is runnable, but it uses standard filter processing rather than deep restoration models.',
'Transcription remains the fastest fallback for judging intelligibility when cleanup cannot fully recover the source.' 'Transcription remains the fastest fallback for judging intelligibility when cleanup cannot fully recover the source.'
], ],
backendPath:'/v1/audio/cleanup', backendPath: ROOT_PATH + '/v1/audio/cleanup',
io:'Input: noisy audio. Output: cleaned audio artifact plus applied-operation metadata.' io:'Input: noisy audio. Output: cleaned audio artifact plus applied-operation metadata.'
},
'embed': {
category:'embed',
label:'Embeddings',
summary:'Generate dense text embedding vectors for semantic search, similarity scoring, and RAG pipelines.',
requires:['embeddings'],
optional:[],
notes:[
'Requires a model with <b>embedding</b> capability configured in the Models page.',
'GGUF text models with embedding support work, as do dedicated sentence-transformer models.',
],
backendPath: ROOT_PATH + '/v1/embeddings',
io:'Input: one or more text strings. Output: floating-point embedding vectors.'
},
'img-faceswap': {
category:'image',
label:'Face Swap',
summary:'Replace faces in images or video frames using the InsightFace/InSwapper backend.',
requires:[],
optional:[],
notes:[
'Requires <code>insightface</code> and <code>onnxruntime</code>: <code>pip install insightface onnxruntime</code>.',
'The <b>inswapper_128.onnx</b> model is <b>auto-downloaded</b> from HuggingFace on first use (<a href="/admin/models?tab=search&q=inswapper&pipeline=&gguf=no-gguf" class="cap-find-link">deepinsight/inswapper<span class="cap-find-icon">↗</span></a>).',
'No AI model selection needed — this feature uses its own dedicated backend.',
],
backendPath: ROOT_PATH + '/v1/images/faceswap',
io:'Input: source face image + target image or video. Output: face-swapped result.'
},
'vid-faceswap': {
category:'video',
label:'Face Swap (Video)',
summary:'Replace faces in video frames using the InsightFace/InSwapper backend.',
requires:[],
optional:[],
notes:[
'Requires <code>insightface</code> and <code>onnxruntime</code>: <code>pip install insightface onnxruntime</code>.',
'The <b>inswapper_128.onnx</b> model is <b>auto-downloaded</b> from HuggingFace on first use (<a href="/admin/models?tab=search&q=inswapper&pipeline=&gguf=no-gguf" class="cap-find-link">deepinsight/inswapper<span class="cap-find-icon">↗</span></a>).',
'No AI model selection needed — this feature uses its own dedicated backend.',
],
backendPath: ROOT_PATH + '/v1/images/faceswap',
io:'Input: source face image + target video. Output: face-swapped video.'
},
'img-deblur': {
category:'image',
label:'Deblur',
summary:'Remove motion blur and sharpen images using built-in signal processing.',
requires:[],
optional:[],
notes:[
'Always available — uses OpenCV Wiener deconvolution, no additional model download required.',
'Works best on images with motion or out-of-focus blur; severe lens distortion may not fully recover.',
],
backendPath: ROOT_PATH + '/v1/images/deblur',
io:'Input: blurred image. Output: sharpened image.'
},
'img-unpix': {
category:'image',
label:'Unpixelate / Upscale',
summary:'Restore pixelated or low-resolution images using Real-ESRGAN super-resolution.',
requires:[],
optional:[],
notes:[
'The Real-ESRGAN model is <b>auto-downloaded</b> from HuggingFace on first use — no manual setup needed.',
'GPU (CUDA) is recommended for fast processing; CPU fallback is available but significantly slower.',
],
backendPath: ROOT_PATH + '/v1/images/unpixelate',
io:'Input: low-resolution or pixelated image. Output: upscaled image.'
} }
}; };
// Maps a capability token to the best HuggingFace search parameters // Maps a capability token to the best HuggingFace search parameters
...@@ -2357,10 +2426,10 @@ const SUB_CAPABILITY_RULES = { ...@@ -2357,10 +2426,10 @@ const SUB_CAPABILITY_RULES = {
'img-upscale': { category:'image', requiresAny:['image_upscaling'] }, 'img-upscale': { category:'image', requiresAny:['image_upscaling'] },
'img-depth': { category:'image', requiresAny:['depth_estimation'] }, 'img-depth': { category:'image', requiresAny:['depth_estimation'] },
'img-seg': { category:'image', requiresAny:['image_segmentation'] }, 'img-seg': { category:'image', requiresAny:['image_segmentation'] },
'img-faceswap': { category:'image', optional:['image_to_image'] }, 'img-faceswap': { category:'image' },
'img-deblur': { category:'image', optional:['image_to_image'] }, 'img-deblur': { category:'image' },
'img-unpix': { category:'image', optional:['image_to_image'] }, 'img-unpix': { category:'image' },
'img-outfit': { category:'image', optional:['image_to_image'] }, 'img-outfit': { category:'image', requiresAny:['inpainting','image_to_image'] },
'vid-t2v': { category:'video', requiresAny:['video_generation'] }, 'vid-t2v': { category:'video', requiresAny:['video_generation'] },
'vid-i2v': { category:'video', requiresAny:['image_to_video'] }, 'vid-i2v': { category:'video', requiresAny:['image_to_video'] },
'vid-v2v': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] }, 'vid-v2v': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] },
...@@ -2369,8 +2438,8 @@ const SUB_CAPABILITY_RULES = { ...@@ -2369,8 +2438,8 @@ const SUB_CAPABILITY_RULES = {
'vid-sub': { category:'video', optional:['subtitle_generation'], fallbackTypes:['video'] }, 'vid-sub': { category:'video', optional:['subtitle_generation'], fallbackTypes:['video'] },
'vid-dub': { category:'video', optional:['subtitle_generation','speech_to_text','text_to_speech'], fallbackTypes:['video'] }, 'vid-dub': { category:'video', optional:['subtitle_generation','speech_to_text','text_to_speech'], fallbackTypes:['video'] },
'vid-up': { category:'video', requiresAny:['video_upscaling'], fallbackTypes:['video'] }, 'vid-up': { category:'video', requiresAny:['video_upscaling'], fallbackTypes:['video'] },
'vid-faceswap': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] }, 'vid-faceswap': { category:'video' },
'vid-outfit': { category:'video', optional:['video_to_video'], fallbackTypes:['video'] }, 'vid-outfit': { category:'video', requiresAny:['inpainting','image_to_image'], fallbackTypes:['video'] },
'img-to3d': { category:'image', optional:['depth_estimation','image_to_3d'], fallbackTypes:['image'] }, 'img-to3d': { category:'image', optional:['depth_estimation','image_to_3d'], fallbackTypes:['image'] },
'img-from3d': { category:'image', optional:['model_3d_to_image'], fallbackTypes:['image'] }, 'img-from3d': { category:'image', optional:['model_3d_to_image'], fallbackTypes:['image'] },
'vid-to3d': { category:'video', optional:['depth_estimation','video_to_3d'], fallbackTypes:['video'] }, 'vid-to3d': { category:'video', optional:['depth_estimation','video_to_3d'], fallbackTypes:['video'] },
...@@ -2388,6 +2457,7 @@ const SUB_CAPABILITY_RULES = { ...@@ -2388,6 +2457,7 @@ const SUB_CAPABILITY_RULES = {
'aud-understand': { category:'audio', optional:['speech_to_text','text_generation'], fallbackTypes:['audio','text','vision'] }, 'aud-understand': { category:'audio', optional:['speech_to_text','text_generation'], fallbackTypes:['audio','text','vision'] },
'aud-stems': { category:'audio', optional:['audio_generation'], fallbackTypes:['audio'] }, 'aud-stems': { category:'audio', optional:['audio_generation'], fallbackTypes:['audio'] },
'aud-clean': { category:'audio', optional:['speech_to_text'], fallbackTypes:['audio'] }, 'aud-clean': { category:'audio', optional:['speech_to_text'], fallbackTypes:['audio'] },
'embed': { requiresAny:['embeddings'] },
}; };
const CATEGORY_TABS = ['chat', 'image', 'video', 'audio', '3d', 'profiles', 'pipe', 'embed', 'archive']; const CATEGORY_TABS = ['chat', 'image', 'video', 'audio', '3d', 'profiles', 'pipe', 'embed', 'archive'];
const CATEGORY_SUBS = { const CATEGORY_SUBS = {
...@@ -2895,7 +2965,7 @@ function previewExportBody(endpoint, body) { ...@@ -2895,7 +2965,7 @@ function previewExportBody(endpoint, body) {
} }
function buildAudioPreviewData() { function buildAudioPreviewData() {
return previewExportBody('/v1/audio/generate', { return previewExportBody(ROOT_PATH + '/v1/audio/generate', {
model: activeModel?.id || '', model: activeModel?.id || '',
prompt: val('ag-prompt'), prompt: val('ag-prompt'),
duration: fval('ag-dur') || 10, duration: fval('ag-dur') || 10,
...@@ -2909,7 +2979,7 @@ function buildAudioPreviewData() { ...@@ -2909,7 +2979,7 @@ function buildAudioPreviewData() {
} }
function buildTTSPreviewData() { function buildTTSPreviewData() {
return previewExportBody('/v1/audio/speech', { return previewExportBody(ROOT_PATH + '/v1/audio/speech', {
model: activeModel?.id || '', model: activeModel?.id || '',
input: val('at-text'), input: val('at-text'),
voice: val('at-voice') || undefined, voice: val('at-voice') || undefined,
...@@ -2920,7 +2990,7 @@ function buildTTSPreviewData() { ...@@ -2920,7 +2990,7 @@ function buildTTSPreviewData() {
function buildSTTPreviewData() { function buildSTTPreviewData() {
const sttModel = bestModelForCap('speech_to_text') || activeModel; const sttModel = bestModelForCap('speech_to_text') || activeModel;
return previewExportBody('/v1/audio/transcriptions', { return previewExportBody(ROOT_PATH + '/v1/audio/transcriptions', {
model: sttModel?.id || '', model: sttModel?.id || '',
file: fileOrNull('as-file') ? '<multipart audio/video file>' : undefined, file: fileOrNull('as-file') ? '<multipart audio/video file>' : undefined,
language: val('as-lang') || undefined, language: val('as-lang') || undefined,
...@@ -2930,7 +3000,7 @@ function buildSTTPreviewData() { ...@@ -2930,7 +3000,7 @@ function buildSTTPreviewData() {
} }
function buildImageGenPreviewData() { function buildImageGenPreviewData() {
return previewExportBody('/v1/images/generations', { return previewExportBody(ROOT_PATH + '/v1/images/generations', {
model: activeModel?.id || '', model: activeModel?.id || '',
prompt: val('ig-prompt'), prompt: val('ig-prompt'),
negative_prompt: val('ig-neg') || undefined, negative_prompt: val('ig-neg') || undefined,
...@@ -2947,7 +3017,7 @@ function buildImageGenPreviewData() { ...@@ -2947,7 +3017,7 @@ function buildImageGenPreviewData() {
function buildEmbeddingsPreviewData() { function buildEmbeddingsPreviewData() {
const lines = val('em-text').split('\n').filter(l => l.trim()); const lines = val('em-text').split('\n').filter(l => l.trim());
const input = lines.length <= 1 ? (lines[0] || '') : lines; const input = lines.length <= 1 ? (lines[0] || '') : lines;
return previewExportBody('/v1/embeddings', { return previewExportBody(ROOT_PATH + '/v1/embeddings', {
model: activeModel?.id || '', model: activeModel?.id || '',
input, input,
encoding_format: val('em-enc') || 'float', encoding_format: val('em-enc') || 'float',
...@@ -2956,7 +3026,7 @@ function buildEmbeddingsPreviewData() { ...@@ -2956,7 +3026,7 @@ function buildEmbeddingsPreviewData() {
} }
function buildAudioUnderstandPreviewData() { function buildAudioUnderstandPreviewData() {
return previewExportBody('/v1/pipelines/audio-understand', { return previewExportBody(ROOT_PATH + '/v1/pipelines/audio-understand', {
audio: fileOrNull('au-file') ? '<audio/video file data>' : undefined, audio: fileOrNull('au-file') ? '<audio/video file data>' : undefined,
audio_model: activeModel?.id || '', audio_model: activeModel?.id || '',
text_model: val('au-text-model') || undefined, text_model: val('au-text-model') || undefined,
...@@ -2966,7 +3036,7 @@ function buildAudioUnderstandPreviewData() { ...@@ -2966,7 +3036,7 @@ function buildAudioUnderstandPreviewData() {
} }
function buildMusicDubPreviewData() { function buildMusicDubPreviewData() {
return previewExportBody('/v1/pipelines/audio-music-dub', { return previewExportBody(ROOT_PATH + '/v1/pipelines/audio-music-dub', {
audio: fileOrNull('amd-file') ? '<audio/video file data>' : undefined, audio: fileOrNull('amd-file') ? '<audio/video file data>' : undefined,
stt_model: getAssignedModelId('aud-music-dub', 'speech_to_text'), stt_model: getAssignedModelId('aud-music-dub', 'speech_to_text'),
tts_model: getAssignedModelId('aud-music-dub', 'text_to_speech'), tts_model: getAssignedModelId('aud-music-dub', 'text_to_speech'),
...@@ -2977,7 +3047,7 @@ function buildMusicDubPreviewData() { ...@@ -2977,7 +3047,7 @@ function buildMusicDubPreviewData() {
} }
function buildStemPreviewData() { function buildStemPreviewData() {
return previewExportBody('/v1/audio/stems', { return previewExportBody(ROOT_PATH + '/v1/audio/stems', {
audio: fileOrNull('ast-file') ? '<audio/video file data>' : undefined, audio: fileOrNull('ast-file') ? '<audio/video file data>' : undefined,
stem_mode: val('ast-mode') || 'vocals-instrumental', stem_mode: val('ast-mode') || 'vocals-instrumental',
response_format: 'url', response_format: 'url',
...@@ -2985,7 +3055,7 @@ function buildStemPreviewData() { ...@@ -2985,7 +3055,7 @@ function buildStemPreviewData() {
} }
function buildCleanupPreviewData() { function buildCleanupPreviewData() {
return previewExportBody('/v1/audio/cleanup', { return previewExportBody(ROOT_PATH + '/v1/audio/cleanup', {
audio: fileOrNull('ac-file') ? '<audio/video file data>' : undefined, audio: fileOrNull('ac-file') ? '<audio/video file data>' : undefined,
noise_reduction: chk('ac-noise'), noise_reduction: chk('ac-noise'),
normalize: chk('ac-level'), normalize: chk('ac-level'),
...@@ -2997,7 +3067,7 @@ function buildCleanupPreviewData() { ...@@ -2997,7 +3067,7 @@ function buildCleanupPreviewData() {
function buildDubPreviewData() { function buildDubPreviewData() {
const preview = buildDubPreferencePreview(); const preview = buildDubPreferencePreview();
return previewExportBody('/v1/video/dub', { return previewExportBody(ROOT_PATH + '/v1/video/dub', {
stt_model: getAssignedModelId('vid-dub', 'speech_to_text'), stt_model: getAssignedModelId('vid-dub', 'speech_to_text'),
tts_model: getAssignedModelId('vid-dub', 'text_to_speech'), tts_model: getAssignedModelId('vid-dub', 'text_to_speech'),
video: fileOrNull('vd-src') ? '<video file data>' : undefined, video: fileOrNull('vd-src') ? '<video file data>' : undefined,
...@@ -3259,7 +3329,7 @@ function deduplicateModels(raw) { ...@@ -3259,7 +3329,7 @@ function deduplicateModels(raw) {
async function loadModels() { async function loadModels() {
try { try {
const d = await fetch('/v1/models').then(r => r.json()); const d = await fetch(ROOT_PATH + '/v1/models').then(r => r.json());
models = deduplicateModels(d.data || []); models = deduplicateModels(d.data || []);
renderSidebar(); renderSidebar();
if (models.length) selectModel(models[0]); if (models.length) selectModel(models[0]);
...@@ -3270,7 +3340,7 @@ async function loadModels() { ...@@ -3270,7 +3340,7 @@ async function loadModels() {
async function loadLocalCapabilities() { async function loadLocalCapabilities() {
try { try {
const r = await fetch('/admin/api/cached-models'); const r = await fetch(ROOT_PATH + '/admin/api/cached-models');
if (!r.ok) return; if (!r.ok) return;
const d = await r.json(); const d = await r.json();
_localCapSet.clear(); _localCapSet.clear();
...@@ -3283,9 +3353,10 @@ async function loadLocalCapabilities() { ...@@ -3283,9 +3353,10 @@ async function loadLocalCapabilities() {
} }
const BADGE = {text:'mb-text',vision:'mb-vision',image:'mb-image',video:'mb-video', const BADGE = {text:'mb-text',vision:'mb-vision',image:'mb-image',video:'mb-video',
audio:'mb-audio',tts:'mb-tts',audio_gen:'mb-audiogen',embedding:'mb-embed'}; audio:'mb-audio',tts:'mb-tts',audio_gen:'mb-audiogen',embedding:'mb-embed',
spatial:'mb-image'};
const BLABEL = {text:'LLM',vision:'VLM',image:'IMG',video:'VID',audio:'STT', const BLABEL = {text:'LLM',vision:'VLM',image:'IMG',video:'VID',audio:'STT',
tts:'TTS',audio_gen:'MUS',embedding:'EMB'}; tts:'TTS',audio_gen:'MUS',embedding:'EMB',spatial:'SPA'};
function renderSidebar() { function renderSidebar() {
const el = $('model-list'); const el = $('model-list');
...@@ -3368,8 +3439,8 @@ function updateTabs(m) { ...@@ -3368,8 +3439,8 @@ function updateTabs(m) {
} }
subStates[sub] = evaluateSubCapability(rule, allCaps, allTypes); subStates[sub] = evaluateSubCapability(rule, allCaps, allTypes);
}); });
// Profile subs require no model capability — always available // Profile subs and self-contained backends require no model capability — always available
['prof-char', 'prof-env', 'prof-voice'].forEach(sub => { subStates[sub] = 'available'; }); ['prof-char', 'prof-env', 'prof-voice', 'img-faceswap', 'vid-faceswap', 'img-deblur', 'img-unpix'].forEach(sub => { subStates[sub] = 'available'; });
updatePipelineBadges(); updatePipelineBadges();
const categoryStates = {}; const categoryStates = {};
CATEGORY_TABS.forEach(cat => { CATEGORY_TABS.forEach(cat => {
...@@ -3532,9 +3603,6 @@ const SUB_API_CAP = { ...@@ -3532,9 +3603,6 @@ const SUB_API_CAP = {
'img-upscale': 'image_upscaling', 'img-upscale': 'image_upscaling',
'img-depth': 'depth_estimation', 'img-depth': 'depth_estimation',
'img-seg': 'image_segmentation', 'img-seg': 'image_segmentation',
'img-faceswap': 'image_to_image',
'img-deblur': 'image_to_image',
'img-unpix': 'image_to_image',
'img-outfit': 'image_to_image', 'img-outfit': 'image_to_image',
'vid-t2v': 'video_generation', 'vid-t2v': 'video_generation',
'vid-i2v': 'image_to_video', 'vid-i2v': 'image_to_video',
...@@ -3647,6 +3715,19 @@ function selectSub(sub) { ...@@ -3647,6 +3715,19 @@ function selectSub(sub) {
// When switching to vid-faceswap, pre-select video mode // When switching to vid-faceswap, pre-select video mode
if (sub === 'vid-faceswap') { const t = $('fs-type'); if (t) { t.value='video'; fsFaceSwapTypeChange(); } } if (sub === 'vid-faceswap') { const t = $('fs-type'); if (t) { t.value='video'; fsFaceSwapTypeChange(); } }
if (sub === 'vid-outfit') { const t = $('ot-type'); if (t) { t.value='video'; otOutfitTypeChange(); } } if (sub === 'vid-outfit') { const t = $('ot-type'); if (t) { t.value='video'; otOutfitTypeChange(); } }
// Toggle shared cap-cards on faceswap and outfit panels
if (sub === 'vid-faceswap' || sub === 'img-faceswap') {
const isVid = sub === 'vid-faceswap';
const ic = $('cap-img-faceswap'), vc = $('cap-vid-faceswap');
if (ic) ic.style.display = isVid ? 'none' : '';
if (vc) vc.style.display = isVid ? '' : 'none';
}
if (sub === 'vid-outfit' || sub === 'img-outfit') {
const isVid = sub === 'vid-outfit';
const ic = $('cap-img-outfit'), vc = $('cap-vid-outfit');
if (ic) ic.style.display = isVid ? 'none' : '';
if (vc) vc.style.display = isVid ? '' : 'none';
}
if (SUB_CAT[sub]) { autoAssignModels(sub); highlightSidebarForSub(sub); } if (SUB_CAT[sub]) { autoAssignModels(sub); highlightSidebarForSub(sub); }
} }
...@@ -3745,7 +3826,7 @@ async function sendChat() { ...@@ -3745,7 +3826,7 @@ async function sendChat() {
$('typing').textContent = 'Thinking…'; $('typing').textContent = 'Thinking…';
const _chatT0 = Date.now(); const _chatT0 = Date.now();
try { try {
const r = await fetch('/v1/chat/completions',{ const r = await fetch(ROOT_PATH + '/v1/chat/completions',{
method:'POST',headers:{'Content-Type':'application/json'}, method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:activeModel.id,messages:chatHistory,stream:false}) body:JSON.stringify({model:activeModel.id,messages:chatHistory,stream:false})
}); });
...@@ -3810,12 +3891,12 @@ function audSrc(d) { ...@@ -3810,12 +3891,12 @@ function audSrc(d) {
} }
async function post(path, body) { async function post(path, body) {
const r = await fetch(path, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); const r = await fetch(ROOT_PATH + path, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
return r.json(); return r.json();
} }
async function postForm(path, fd) { async function postForm(path, fd) {
const r = await fetch(path, {method:'POST', body:fd}); const r = await fetch(ROOT_PATH + path, {method:'POST', body:fd});
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
return r.json(); return r.json();
} }
...@@ -3826,7 +3907,7 @@ async function postForm(path, fd) { ...@@ -3826,7 +3907,7 @@ async function postForm(path, fd) {
// ───────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────
async function loadCharProfileList() { async function loadCharProfileList() {
try { try {
const d = await fetch('/v1/characters').then(r => r.json()); const d = await fetch(ROOT_PATH + '/v1/characters').then(r => r.json());
_charProfiles = d.characters || []; _charProfiles = d.characters || [];
} catch(e) { _charProfiles = []; } } catch(e) { _charProfiles = []; }
} }
...@@ -3895,7 +3976,7 @@ function removeCharSlotImage(prefix, idx, imgIdx) { ...@@ -3895,7 +3976,7 @@ function removeCharSlotImage(prefix, idx, imgIdx) {
async function loadCharProfileIntoSlot(prefix, idx, name) { async function loadCharProfileIntoSlot(prefix, idx, name) {
try { try {
const d = await fetch(`/v1/characters/${encodeURIComponent(name)}`).then(r => r.json()); const d = await fetch(ROOT_PATH + `/v1/characters/${encodeURIComponent(name)}`).then(r => r.json());
if (!charSlots[prefix]?.[idx]) return; if (!charSlots[prefix]?.[idx]) return;
charSlots[prefix][idx].name = charSlots[prefix][idx].name || d.name; charSlots[prefix][idx].name = charSlots[prefix][idx].name || d.name;
charSlots[prefix][idx].images = (d.images||[]).map(img => img.data); charSlots[prefix][idx].images = (d.images||[]).map(img => img.data);
...@@ -4180,7 +4261,7 @@ async function genImage() { ...@@ -4180,7 +4261,7 @@ async function genImage() {
$('ig-prog').scrollIntoView({behavior:'smooth', block:'nearest'}); $('ig-prog').scrollIntoView({behavior:'smooth', block:'nearest'});
_imgPollTimer = setInterval(async()=>{ _imgPollTimer = setInterval(async()=>{
try{ try{
const p=await (await fetch('/v1/images/progress')).json(); const p=await (await fetch(ROOT_PATH + '/v1/images/progress')).json();
if(p.total>0){ if(p.total>0){
fill.style.width=p.pct+'%'; fill.style.width=p.pct+'%';
const spd = p.it_per_s>0 ? ` · ${p.it_per_s} it/s` : (p.elapsed>0 ? ` · ${p.elapsed}s` : ''); const spd = p.it_per_s>0 ? ` · ${p.it_per_s} it/s` : (p.elapsed>0 ? ` · ${p.elapsed}s` : '');
...@@ -4965,7 +5046,7 @@ let _voiceProfiles = []; ...@@ -4965,7 +5046,7 @@ let _voiceProfiles = [];
async function loadVoiceProfiles() { async function loadVoiceProfiles() {
try { try {
const d = await fetch('/v1/audio/voices').then(r => r.json()); const d = await fetch(ROOT_PATH + '/v1/audio/voices').then(r => r.json());
_voiceProfiles = d.voices || []; _voiceProfiles = d.voices || [];
['vc-voice', 'pp4-voice', 'cv-voice', 'pvc-voice', 'psvc-voice'].forEach(id => { ['vc-voice', 'pp4-voice', 'cv-voice', 'pvc-voice', 'psvc-voice'].forEach(id => {
const sel = $(id); if (!sel) return; const sel = $(id); if (!sel) return;
...@@ -4988,7 +5069,7 @@ async function saveVoiceProfile() { ...@@ -4988,7 +5069,7 @@ async function saveVoiceProfile() {
fd.append('description', val('vc-savedesc')); fd.append('description', val('vc-savedesc'));
fd.append('audio', f); fd.append('audio', f);
try { try {
const r = await fetch('/v1/audio/voices', {method:'POST', body:fd}); const r = await fetch(ROOT_PATH + '/v1/audio/voices', {method:'POST', body:fd});
if (!r.ok) throw new Error(await r.text()); if (!r.ok) throw new Error(await r.text());
$('vc-saveprog').textContent='Saved ✓'; $('vc-saveprog').textContent='Saved ✓';
await loadVoiceProfiles(); await loadVoiceProfiles();
...@@ -5174,7 +5255,7 @@ async function genTTS() { ...@@ -5174,7 +5255,7 @@ async function genTTS() {
$('at-prog').textContent='Synthesizing…'; $('at-prog').textContent='Synthesizing…';
try { try {
const ttsVoiceProfile = val('tts-voice-profile'); const ttsVoiceProfile = val('tts-voice-profile');
const r = await fetch('/v1/audio/speech', { const r = await fetch(ROOT_PATH + '/v1/audio/speech', {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({model:modelForSub('aud-tts'), input:val('at-text'), body:JSON.stringify({model:modelForSub('aud-tts'), input:val('at-text'),
speed:fval('at-speed')||1.0, voice:val('at-voice')||undefined, response_format:'mp3', speed:fval('at-speed')||1.0, voice:val('at-voice')||undefined, response_format:'mp3',
...@@ -5598,7 +5679,7 @@ async function profCharSave() { ...@@ -5598,7 +5679,7 @@ async function profCharSave() {
body.videos.push(b64); body.videos.push(b64);
} }
} }
const r = await fetch('/v1/characters/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); const r = await fetch(ROOT_PATH + '/v1/characters/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!r.ok) throw new Error((await r.json()).detail || await r.text()); if (!r.ok) throw new Error((await r.json()).detail || await r.text());
const d = await r.json(); const d = await r.json();
$('pc-form-status').textContent = `Saved "${d.name}" (${d.image_count} reference images) ✓`; $('pc-form-status').textContent = `Saved "${d.name}" (${d.image_count} reference images) ✓`;
...@@ -5626,7 +5707,7 @@ async function profCharGenerate() { ...@@ -5626,7 +5707,7 @@ async function profCharGenerate() {
if (mdl) body.model = mdl; if (mdl) body.model = mdl;
const steps = parseInt($('pc-gen-steps').value); const steps = parseInt($('pc-gen-steps').value);
if (steps > 0) body.steps = steps; if (steps > 0) body.steps = steps;
const r = await fetch('/v1/characters/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); const r = await fetch(ROOT_PATH + '/v1/characters/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!r.ok) throw new Error((await r.json()).detail || await r.text()); if (!r.ok) throw new Error((await r.json()).detail || await r.text());
const d = await r.json(); const d = await r.json();
$('pc-form-status').textContent = `Generated "${d.name}" (${d.image_count} reference images) ✓`; $('pc-form-status').textContent = `Generated "${d.name}" (${d.image_count} reference images) ✓`;
...@@ -5637,7 +5718,7 @@ async function profCharGenerate() { ...@@ -5637,7 +5718,7 @@ async function profCharGenerate() {
async function profCharLoad() { async function profCharLoad() {
try { try {
const d = await fetch('/admin/api/characters').then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/characters').then(r=>r.json());
_charProfiles = d.characters || []; _charProfiles = d.characters || [];
renderCharList(); renderCharList();
// Ensure every panel has at least one slot, then refresh options // Ensure every panel has at least one slot, then refresh options
...@@ -5663,7 +5744,7 @@ function renderCharList() { ...@@ -5663,7 +5744,7 @@ function renderCharList() {
el.innerHTML = _charProfiles.map(p => { el.innerHTML = _charProfiles.map(p => {
const n = escapeHtml(p.name); const n = escapeHtml(p.name);
const thumb = p.image_count > 0 const thumb = p.image_count > 0
? `<img class="arch-thumb" src="/admin/api/characters/${encodeURIComponent(p.name)}/thumbnail" loading="lazy" onerror="this.outerHTML='<div class=arch-thumb-ph>\u{1F464}</div>'" onclick="profCharView('${n}')">` ? `<img class="arch-thumb" src="${ROOT_PATH}/admin/api/characters/${encodeURIComponent(p.name)}/thumbnail" loading="lazy" onerror="this.outerHTML='<div class=arch-thumb-ph>\u{1F464}</div>'" onclick="profCharView('${n}')">`
: `<div class="arch-thumb-ph" onclick="profCharView('${n}')">\u{1F464}</div>`; : `<div class="arch-thumb-ph" onclick="profCharView('${n}')">\u{1F464}</div>`;
const date = new Date(p.created_at * 1000).toLocaleDateString(); const date = new Date(p.created_at * 1000).toLocaleDateString();
return ` return `
...@@ -5683,7 +5764,7 @@ function renderCharList() { ...@@ -5683,7 +5764,7 @@ function renderCharList() {
} }
async function profCharView(name) { async function profCharView(name) {
const d = await fetch('/admin/api/characters/'+encodeURIComponent(name)).then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/characters/'+encodeURIComponent(name)).then(r=>r.json());
const imgs = (d.images||[]).map(img=>`<img src="${img.data}" style="height:80px;border-radius:4px;object-fit:cover" title="${escapeHtml(img.label||'')}">`).join(''); const imgs = (d.images||[]).map(img=>`<img src="${img.data}" style="height:80px;border-radius:4px;object-fit:cover" title="${escapeHtml(img.label||'')}">`).join('');
alert(`Character: ${d.name}\nDescription: ${d.description||'—'}\nImages: ${d.image_count}\n\n(Images are shown in console; open DevTools to inspect)`); alert(`Character: ${d.name}\nDescription: ${d.description||'—'}\nImages: ${d.image_count}\n\n(Images are shown in console; open DevTools to inspect)`);
console.log('[profCharView]', d.name, d); console.log('[profCharView]', d.name, d);
...@@ -5691,7 +5772,7 @@ async function profCharView(name) { ...@@ -5691,7 +5772,7 @@ async function profCharView(name) {
async function profCharDelete(name) { async function profCharDelete(name) {
if (!confirm(`Delete character profile "${name}"?`)) return; if (!confirm(`Delete character profile "${name}"?`)) return;
const r = await fetch('/admin/api/characters/'+encodeURIComponent(name), {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/characters/'+encodeURIComponent(name), {method:'DELETE'});
if (r.ok) await profCharLoad(); if (r.ok) await profCharLoad();
else alert('Delete failed: ' + await r.text()); else alert('Delete failed: ' + await r.text());
} }
...@@ -5718,7 +5799,7 @@ async function profVoiceSave() { ...@@ -5718,7 +5799,7 @@ async function profVoiceSave() {
const isVideo = f.type.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/i.test(f.name); const isVideo = f.type.startsWith('video/') || /\.(mp4|mov|mkv|avi|webm)$/i.test(f.name);
const body = { name, description: $('pv-desc').value||'', transcript: $('pv-transcript').value||'' }; const body = { name, description: $('pv-desc').value||'', transcript: $('pv-transcript').value||'' };
if (isVideo) body.video = b64; else body.audio = b64; if (isVideo) body.video = b64; else body.audio = b64;
const r = await fetch('/v1/audio/voices/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); const r = await fetch(ROOT_PATH + '/v1/audio/voices/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!r.ok) throw new Error((await r.json()).detail || await r.text()); if (!r.ok) throw new Error((await r.json()).detail || await r.text());
const d = await r.json(); const d = await r.json();
const transcript = d.voice?.transcript; const transcript = d.voice?.transcript;
...@@ -5730,7 +5811,7 @@ async function profVoiceSave() { ...@@ -5730,7 +5811,7 @@ async function profVoiceSave() {
async function profVoiceLoad() { async function profVoiceLoad() {
try { try {
const d = await fetch('/admin/api/voices').then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/voices').then(r=>r.json());
const voices = d.voices || []; const voices = d.voices || [];
renderVoiceList(voices); renderVoiceList(voices);
// Refresh voice selectors (existing + new) // Refresh voice selectors (existing + new)
...@@ -5772,7 +5853,7 @@ function renderVoiceList(voices) { ...@@ -5772,7 +5853,7 @@ function renderVoiceList(voices) {
async function profVoiceDelete(name) { async function profVoiceDelete(name) {
if (!confirm(`Delete voice profile "${name}"?`)) return; if (!confirm(`Delete voice profile "${name}"?`)) return;
const r = await fetch('/admin/api/voices/'+encodeURIComponent(name), {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/voices/'+encodeURIComponent(name), {method:'DELETE'});
if (r.ok) await profVoiceLoad(); if (r.ok) await profVoiceLoad();
else alert('Delete failed: ' + await r.text()); else alert('Delete failed: ' + await r.text());
} }
...@@ -5855,7 +5936,7 @@ async function profEnvSave() { ...@@ -5855,7 +5936,7 @@ async function profEnvSave() {
body.videos.push(b64); body.videos.push(b64);
} }
} }
const r = await fetch('/v1/environments/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); const r = await fetch(ROOT_PATH + '/v1/environments/extract', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!r.ok) throw new Error((await r.json()).detail || await r.text()); if (!r.ok) throw new Error((await r.json()).detail || await r.text());
const d = await r.json(); const d = await r.json();
$('pe-form-status').textContent = `Saved "${d.name}" (${d.image_count} reference images) ✓`; $('pe-form-status').textContent = `Saved "${d.name}" (${d.image_count} reference images) ✓`;
...@@ -5881,7 +5962,7 @@ async function profEnvGenerate() { ...@@ -5881,7 +5962,7 @@ async function profEnvGenerate() {
if (mdl) body.model = mdl; if (mdl) body.model = mdl;
const steps = parseInt($('pe-gen-steps').value); const steps = parseInt($('pe-gen-steps').value);
if (steps > 0) body.steps = steps; if (steps > 0) body.steps = steps;
const r = await fetch('/v1/environments/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); const r = await fetch(ROOT_PATH + '/v1/environments/generate', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
if (!r.ok) throw new Error((await r.json()).detail || await r.text()); if (!r.ok) throw new Error((await r.json()).detail || await r.text());
const d = await r.json(); const d = await r.json();
$('pe-form-status').textContent = `Generated "${d.name}" (${d.image_count} reference images) ✓`; $('pe-form-status').textContent = `Generated "${d.name}" (${d.image_count} reference images) ✓`;
...@@ -5892,7 +5973,7 @@ async function profEnvGenerate() { ...@@ -5892,7 +5973,7 @@ async function profEnvGenerate() {
async function profEnvLoad() { async function profEnvLoad() {
try { try {
const d = await fetch('/admin/api/environments').then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/environments').then(r=>r.json());
_envProfiles = d.environments || []; _envProfiles = d.environments || [];
renderEnvList(); renderEnvList();
initEnvProfileSlots(); initEnvProfileSlots();
...@@ -5907,7 +5988,7 @@ function renderEnvList() { ...@@ -5907,7 +5988,7 @@ function renderEnvList() {
el.innerHTML = _envProfiles.map(p => { el.innerHTML = _envProfiles.map(p => {
const n = escapeHtml(p.name); const n = escapeHtml(p.name);
const thumb = p.image_count > 0 const thumb = p.image_count > 0
? `<img class="arch-thumb" src="/admin/api/environments/${encodeURIComponent(p.name)}/thumbnail" loading="lazy" onerror="this.outerHTML='<div class=arch-thumb-ph>\u{1F304}</div>'" onclick="profEnvView('${n}')">` ? `<img class="arch-thumb" src="${ROOT_PATH}/admin/api/environments/${encodeURIComponent(p.name)}/thumbnail" loading="lazy" onerror="this.outerHTML='<div class=arch-thumb-ph>\u{1F304}</div>'" onclick="profEnvView('${n}')">`
: `<div class="arch-thumb-ph" onclick="profEnvView('${n}')">\u{1F304}</div>`; : `<div class="arch-thumb-ph" onclick="profEnvView('${n}')">\u{1F304}</div>`;
const date = new Date(p.created_at * 1000).toLocaleDateString(); const date = new Date(p.created_at * 1000).toLocaleDateString();
return ` return `
...@@ -5927,14 +6008,14 @@ function renderEnvList() { ...@@ -5927,14 +6008,14 @@ function renderEnvList() {
} }
async function profEnvView(name) { async function profEnvView(name) {
const d = await fetch('/admin/api/environments/'+encodeURIComponent(name)).then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/environments/'+encodeURIComponent(name)).then(r=>r.json());
alert(`Environment: ${d.name}\nDescription: ${d.description||''}\nImages: ${d.image_count}\n\n(Images are shown in console; open DevTools to inspect)`); alert(`Environment: ${d.name}\nDescription: ${d.description||''}\nImages: ${d.image_count}\n\n(Images are shown in console; open DevTools to inspect)`);
console.log('[profEnvView]', d.name, d); console.log('[profEnvView]', d.name, d);
} }
async function profEnvDelete(name) { async function profEnvDelete(name) {
if (!confirm(`Delete environment profile "${name}"?`)) return; if (!confirm(`Delete environment profile "${name}"?`)) return;
const r = await fetch('/admin/api/environments/'+encodeURIComponent(name), {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/environments/'+encodeURIComponent(name), {method:'DELETE'});
if (r.ok) await profEnvLoad(); if (r.ok) await profEnvLoad();
else alert('Delete failed: ' + await r.text()); else alert('Delete failed: ' + await r.text());
} }
...@@ -5948,7 +6029,7 @@ profEnvLoad(); ...@@ -5948,7 +6029,7 @@ profEnvLoad();
profVoiceLoad(); profVoiceLoad();
initRequestPreviews(); initRequestPreviews();
initPipelineBuilder(); initPipelineBuilder();
fetch('/admin/api/tokens').then(r => r.json()).then(tokens => { fetch(ROOT_PATH + '/admin/api/tokens').then(r => r.json()).then(tokens => {
if (Array.isArray(tokens) && tokens.length) apiToken = tokens[0].token; if (Array.isArray(tokens) && tokens.length) apiToken = tokens[0].token;
}).catch(() => {}); }).catch(() => {});
...@@ -5975,8 +6056,8 @@ let _editingPipelineId = null; ...@@ -5975,8 +6056,8 @@ let _editingPipelineId = null;
async function initPipelineBuilder() { async function initPipelineBuilder() {
try { try {
const [typesRes, pipesRes] = await Promise.all([ const [typesRes, pipesRes] = await Promise.all([
fetch('/v1/pipelines/step-types').then(r => r.json()), fetch(ROOT_PATH + '/v1/pipelines/step-types').then(r => r.json()),
fetch('/v1/pipelines/custom').then(r => r.json()), fetch(ROOT_PATH + '/v1/pipelines/custom').then(r => r.json()),
]); ]);
_stepTypes = typesRes.step_types || []; _stepTypes = typesRes.step_types || [];
_customPipelines = pipesRes.pipelines || []; _customPipelines = pipesRes.pipelines || [];
...@@ -6106,12 +6187,12 @@ async function pbSave() { ...@@ -6106,12 +6187,12 @@ async function pbSave() {
try { try {
let res; let res;
if (_editingPipelineId) { if (_editingPipelineId) {
res = await fetch(`/v1/pipelines/custom/${_editingPipelineId}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json()); res = await fetch(ROOT_PATH + `/v1/pipelines/custom/${_editingPipelineId}`, {method:'PUT', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json());
} else { } else {
res = await fetch('/v1/pipelines/custom', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json()); res = await fetch(ROOT_PATH + '/v1/pipelines/custom', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(def)}).then(r => r.json());
_editingPipelineId = res.pipeline?.id; _editingPipelineId = res.pipeline?.id;
} }
_customPipelines = (await fetch('/v1/pipelines/custom').then(r => r.json())).pipelines || []; _customPipelines = (await fetch(ROOT_PATH + '/v1/pipelines/custom').then(r => r.json())).pipelines || [];
renderCustomPipelineCards(); renderCustomPipelineCards();
$('pb-prog').textContent = 'Saved ✓'; $('pb-prog').textContent = 'Saved ✓';
} catch(e) { $('pb-prog').textContent = 'Error: '+e.message; } } catch(e) { $('pb-prog').textContent = 'Error: '+e.message; }
...@@ -6157,7 +6238,7 @@ function editCustomPipeline(id) { ...@@ -6157,7 +6238,7 @@ function editCustomPipeline(id) {
async function deleteCustomPipeline(id) { async function deleteCustomPipeline(id) {
if (!confirm('Delete this pipeline?')) return; if (!confirm('Delete this pipeline?')) return;
try { try {
await fetch(`/v1/pipelines/custom/${id}`, {method:'DELETE'}); await fetch(ROOT_PATH + `/v1/pipelines/custom/${id}`, {method:'DELETE'});
_customPipelines = _customPipelines.filter(p => p.id !== id); _customPipelines = _customPipelines.filter(p => p.id !== id);
if (_editingPipelineId === id) { _editingPipelineId = null; _pbSteps = []; renderBuilderSteps(); } if (_editingPipelineId === id) { _editingPipelineId = null; _pbSteps = []; renderBuilderSteps(); }
renderCustomPipelineCards(); renderCustomPipelineCards();
...@@ -6250,7 +6331,7 @@ async function loadArchive() { ...@@ -6250,7 +6331,7 @@ async function loadArchive() {
if (!grid) return; if (!grid) return;
grid.innerHTML = '<div class="arch-empty">Loading…</div>'; grid.innerHTML = '<div class="arch-empty">Loading…</div>';
try { try {
const data = await (await fetch('/v1/archive')).json(); const data = await (await fetch(ROOT_PATH + '/v1/archive')).json();
_archiveFiles = data.files || []; _archiveFiles = data.files || [];
renderArchive(); renderArchive();
} catch(e) { } catch(e) {
...@@ -6311,7 +6392,7 @@ function renderArchive() { ...@@ -6311,7 +6392,7 @@ function renderArchive() {
async function archiveDelete(filename) { async function archiveDelete(filename) {
if (!confirm(`Delete ${filename}?`)) return; if (!confirm(`Delete ${filename}?`)) return;
try { try {
const r = await fetch(`/v1/archive/${encodeURIComponent(filename)}`, { method: 'DELETE' }); const r = await fetch(ROOT_PATH + `/v1/archive/${encodeURIComponent(filename)}`, { method: 'DELETE' });
if (!r.ok) throw new Error((await r.json()).detail || 'Delete failed'); if (!r.ok) throw new Error((await r.json()).detail || 'Delete failed');
_archiveFiles = _archiveFiles.filter(f => f.filename !== filename); _archiveFiles = _archiveFiles.filter(f => f.filename !== filename);
renderArchive(); renderArchive();
......
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
<div id="active-models"><span class="muted small">No models loaded</span></div> <div id="active-models"><span class="muted small">No models loaded</span></div>
{% if is_admin %} {% if is_admin %}
<div style="margin-top:.875rem"> <div style="margin-top:.875rem">
<a href="/admin/models" class="btn btn-ghost btn-sm">Manage models</a> <a href="{{ root_path }}/admin/models" class="btn btn-ghost btn-sm">Manage models</a>
</div> </div>
{% endif %} {% endif %}
</div> </div>
...@@ -69,7 +69,7 @@ ...@@ -69,7 +69,7 @@
<script> <script>
async function poll() { async function poll() {
try { try {
const d = await fetch('/admin/api/status').then(r => r.json()); const d = await fetch(ROOT_PATH + '/admin/api/status').then(r => r.json());
const ok = d.status === 'ok'; const ok = d.status === 'ok';
document.getElementById('sys-status').textContent = ok ? 'Online' : 'Error'; document.getElementById('sys-status').textContent = ok ? 'Online' : 'Error';
document.getElementById('sys-status').className = 'stat-value small ' + (ok ? 'text-green' : 'text-red'); document.getElementById('sys-status').className = 'stat-value small ' + (ok ? 'text-green' : 'text-red');
......
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/admin/style.css"> <link rel="stylesheet" href="{{ root_path }}/static/admin/style.css">
<script>const ROOT_PATH = "{{ root_path }}";</script>
</head> </head>
<body> <body>
<div class="login-wrap"> <div class="login-wrap">
...@@ -24,7 +25,7 @@ ...@@ -24,7 +25,7 @@
<div class="alert alert-error" style="margin-bottom:1.25rem">{{ error }}</div> <div class="alert alert-error" style="margin-bottom:1.25rem">{{ error }}</div>
{% endif %} {% endif %}
<form method="post" action="/login"> <form method="post" action="{{ root_path }}/login">
<div class="form-row"> <div class="form-row">
<label class="form-label" for="username">Username</label> <label class="form-label" for="username">Username</label>
<input class="form-input" type="text" id="username" name="username" <input class="form-input" type="text" id="username" name="username"
......
...@@ -222,6 +222,9 @@ ...@@ -222,6 +222,9 @@
<span class="chip" data-val="speech_to_text">STT</span> <span class="chip" data-val="speech_to_text">STT</span>
<span class="chip" data-val="text_to_speech">TTS</span> <span class="chip" data-val="text_to_speech">TTS</span>
<span class="chip" data-val="embeddings">Embed</span> <span class="chip" data-val="embeddings">Embed</span>
<span class="chip" data-val="depth_estimation">Depth</span>
<span class="chip" data-val="image_segmentation">Segment</span>
<span class="chip" data-val="object_detection">Detect</span>
<span class="chip" data-val="function-calling">Tool calling</span> <span class="chip" data-val="function-calling">Tool calling</span>
<span class="chip" data-val="vision">Vision</span> <span class="chip" data-val="vision">Vision</span>
<span class="chip" data-val="reasoning">Reasoning</span> <span class="chip" data-val="reasoning">Reasoning</span>
...@@ -428,8 +431,52 @@ ...@@ -428,8 +431,52 @@
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="tts_models"> Text-to-Speech (TTS)</label> <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="tts_models"> Text-to-Speech (TTS)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="audio_gen_models"> Audio generation</label> <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="audio_gen_models"> Audio generation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="embedding_models"> Embeddings</label> <label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="embedding_models"> Embeddings</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:13px"><input type="checkbox" class="cfg-type-cb" value="spatial_models"> Depth / Segmentation / Detection</label>
</div> </div>
</div> </div>
<!-- Capabilities -->
<div class="card-title" style="margin-top:1.25rem;display:flex;align-items:center;gap:.5rem">
Capabilities
<span id="cfg-caps-autodet" style="font-size:11px;color:var(--text-3);font-weight:400"></span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.25rem .75rem">
<div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-bottom:.15rem">Text / Language</div>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="text_generation"> Text generation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="embeddings"> Embeddings</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_text"> Image-to-text (vision input)</label>
<div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Image generation &amp; editing</div>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_generation"> Image generation (T2I)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_image"> Image-to-image (I2I)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="inpainting"> Inpainting</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="controlnet"> ControlNet</label>
<div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Image analysis &amp; processing</div>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="depth_estimation"> Depth estimation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_segmentation"> Image segmentation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="object_detection"> Object detection</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_upscaling"> Image upscaling</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="face_restoration"> Face restoration</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="style_transfer"> Style transfer</label>
<div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Video</div>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_generation"> Video generation (T2V)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_video"> Image-to-video (I2V)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_to_video"> Video-to-video (V2V)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_interpolation"> Frame interpolation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_upscaling"> Video upscaling</label>
<div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">Audio</div>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="speech_to_text"> Speech-to-text (STT)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="text_to_speech"> Text-to-speech (TTS)</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="subtitle_generation"> Subtitle generation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="audio_generation"> Audio generation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="audio_to_audio"> Audio-to-audio</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="lip_sync"> Lip sync</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_dubbing"> Video dubbing</label>
<div style="grid-column:1/-1;font-size:11px;color:var(--text-3);margin-top:.5rem;margin-bottom:.15rem">3D</div>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="image_to_3d"> Image-to-3D</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="video_to_3d"> Video-to-3D</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="model_3d_generation"> 3D model generation</label>
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer;font-size:12px"><input type="checkbox" class="cfg-cap-cb" value="model_3d_to_image"> 3D model to image</label>
</div>
<div class="form-row"> <div class="form-row">
<label class="form-label">Alias <span class="muted">(optional)</span></label> <label class="form-label">Alias <span class="muted">(optional)</span></label>
<input type="text" id="cfg-alias" class="form-input" placeholder="Friendly name"> <input type="text" id="cfg-alias" class="form-input" placeholder="Friendly name">
...@@ -588,7 +635,9 @@ function fmtCapabilities(caps){ ...@@ -588,7 +635,9 @@ function fmtCapabilities(caps){
inpainting:'Inpaint',controlnet:'ControlNet',depth_estimation:'Depth', inpainting:'Inpaint',controlnet:'ControlNet',depth_estimation:'Depth',
image_segmentation:'Segment',image_upscaling:'Upscale',face_restoration:'Face', image_segmentation:'Segment',image_upscaling:'Upscale',face_restoration:'Face',
object_detection:'Detect',video_interpolation:'Interp',video_upscaling:'V-Upscale', object_detection:'Detect',video_interpolation:'Interp',video_upscaling:'V-Upscale',
lip_sync:'Lip-sync',subtitle_generation:'Subs',video_dubbing:'Dub' lip_sync:'Lip-sync',subtitle_generation:'Subs',video_dubbing:'Dub',
style_transfer:'Style',image_to_3d:'I→3D',video_to_3d:'V→3D',
model_3d_generation:'3D-Gen',model_3d_to_image:'3D→Img'
}; };
return caps.slice(0,5).map(c=>`<span class="badge badge-user" style="font-size:10px;padding:.15rem .35rem">${esc(labels[c]||c)}</span>`).join(' '); return caps.slice(0,5).map(c=>`<span class="badge badge-user" style="font-size:10px;padding:.15rem .35rem">${esc(labels[c]||c)}</span>`).join(' ');
} }
...@@ -644,7 +693,7 @@ let _highlightCap = null; // capability to highlight in local models list (from ...@@ -644,7 +693,7 @@ let _highlightCap = null; // capability to highlight in local models list (from
async function loadGlobalSettings(){ async function loadGlobalSettings(){
try{ try{
const r = await fetch('/admin/api/settings'); const r = await fetch(ROOT_PATH + '/admin/api/settings');
if(r.ok){ if(r.ok){
const d = await r.json(); const d = await r.json();
_defaultOffloadDir = d.offload?.directory || './offload'; _defaultOffloadDir = d.offload?.directory || './offload';
...@@ -730,7 +779,7 @@ async function doSearch(){ ...@@ -730,7 +779,7 @@ async function doSearch(){
if(caps.length) params.append('capabilities', caps.join(',')); if(caps.length) params.append('capabilities', caps.join(','));
try{ try{
const r = await fetch('/admin/api/hf-search?'+params); const r = await fetch(ROOT_PATH + '/admin/api/hf-search?'+params);
if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)} if(!r.ok){const e=await r.json();throw new Error(e.detail||r.statusText)}
_results = await r.json(); _results = await r.json();
...@@ -809,7 +858,7 @@ async function toggleFiles(i){ ...@@ -809,7 +858,7 @@ async function toggleFiles(i){
if(_filesCache[modelId]){renderFiles(panel,modelId,_filesCache[modelId]);return} if(_filesCache[modelId]){renderFiles(panel,modelId,_filesCache[modelId]);return}
panel.innerHTML='<span class="muted small">Fetching file list…</span>'; panel.innerHTML='<span class="muted small">Fetching file list…</span>';
try{ try{
const r = await fetch('/admin/api/hf-model-files?model_id='+encodeURIComponent(modelId)); const r = await fetch(ROOT_PATH + '/admin/api/hf-model-files?model_id='+encodeURIComponent(modelId));
if(!r.ok)throw new Error((await r.json()).detail||r.statusText); if(!r.ok)throw new Error((await r.json()).detail||r.statusText);
const files = await r.json(); const files = await r.json();
_filesCache[modelId]=files; _filesCache[modelId]=files;
...@@ -866,7 +915,7 @@ function closeInfo(){ ...@@ -866,7 +915,7 @@ function closeInfo(){
async function loadInfo(modelId){ async function loadInfo(modelId){
try{ try{
const r = await fetch('/admin/api/hf-model-info?model_id='+encodeURIComponent(modelId)); const r = await fetch(ROOT_PATH + '/admin/api/hf-model-info?model_id='+encodeURIComponent(modelId));
if(!r.ok)throw new Error((await r.json()).detail||r.statusText); if(!r.ok)throw new Error((await r.json()).detail||r.statusText);
renderInfo(await r.json()); renderInfo(await r.json());
}catch(e){ }catch(e){
...@@ -1036,13 +1085,13 @@ async function startDownload(){ ...@@ -1036,13 +1085,13 @@ async function startDownload(){
document.getElementById('dl-form').style.display='none'; document.getElementById('dl-form').style.display='none';
document.getElementById('dl-progress').style.display='block'; document.getElementById('dl-progress').style.display='block';
try{ try{
const r=await fetch('/admin/api/model-download',{ const r=await fetch(ROOT_PATH + '/admin/api/model-download',{
method:'POST',headers:{'Content-Type':'application/json'}, method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({model_id:id,file_pattern:document.getElementById('dl-pattern').value||null}) body:JSON.stringify({model_id:id,file_pattern:document.getElementById('dl-pattern').value||null})
}); });
if(!r.ok){const e=await r.json();showDownloadError(e.detail||'Request failed');return} if(!r.ok){const e=await r.json();showDownloadError(e.detail||'Request failed');return}
const {session_id}=await r.json(); const {session_id}=await r.json();
_dlEs=new EventSource('/admin/api/download-stream/'+session_id); _dlEs=new EventSource(ROOT_PATH + '/admin/api/download-stream/'+session_id);
_dlEs.onmessage=function(e){ _dlEs.onmessage=function(e){
try{handleProgressEvent(JSON.parse(e.data))}catch{} try{handleProgressEvent(JSON.parse(e.data))}catch{}
}; };
...@@ -1059,7 +1108,7 @@ let _pollTimer = null; ...@@ -1059,7 +1108,7 @@ let _pollTimer = null;
async function pollDownloads(){ async function pollDownloads(){
try{ try{
const r = await fetch('/admin/api/downloads'); const r = await fetch(ROOT_PATH + '/admin/api/downloads');
if(!r.ok) return; if(!r.ok) return;
const all = await r.json(); const all = await r.json();
const active = all.filter(d=>d.status!=='done'&&d.status!=='error'); const active = all.filter(d=>d.status!=='done'&&d.status!=='error');
...@@ -1099,7 +1148,7 @@ startPolling(); ...@@ -1099,7 +1148,7 @@ startPolling();
/* ── cache stats & local models ──────────────────────── */ /* ── cache stats & local models ──────────────────────── */
async function loadCacheStats(){ async function loadCacheStats(){
try{ try{
const r = await fetch('/admin/api/cache-stats'); const r = await fetch(ROOT_PATH + '/admin/api/cache-stats');
if(!r.ok) return; if(!r.ok) return;
const s = await r.json(); const s = await r.json();
document.getElementById('stat-hf-size').textContent = fmtBytes(s.hf_bytes); document.getElementById('stat-hf-size').textContent = fmtBytes(s.hf_bytes);
...@@ -1220,10 +1269,10 @@ async function loadCachedModels(){ ...@@ -1220,10 +1269,10 @@ async function loadCachedModels(){
const ggufEl = document.getElementById('gguf-models-list'); const ggufEl = document.getElementById('gguf-models-list');
hfEl.innerHTML = ggufEl.innerHTML = '<span class="muted small">Loading…</span>'; hfEl.innerHTML = ggufEl.innerHTML = '<span class="muted small">Loading…</span>';
try{ try{
const r = await fetch('/admin/api/cached-models'); const r = await fetch(ROOT_PATH + '/admin/api/cached-models');
if(!r.ok) throw new Error((await r.json()).detail||r.statusText); if(!r.ok) throw new Error((await r.json()).detail||r.statusText);
const d = await r.json(); const d = await r.json();
const whisperModels = (await fetch('/admin/api/models').then(r=>r.ok?r.json():[])) const whisperModels = (await fetch(ROOT_PATH + '/admin/api/models').then(r=>r.ok?r.json():[]))
.filter(m => m.backend === 'whisper-server'); .filter(m => m.backend === 'whisper-server');
// HF models // HF models
...@@ -1371,7 +1420,7 @@ let _instanceInfo = {}; // loaded_key -> {loaded: N, max: N} ...@@ -1371,7 +1420,7 @@ let _instanceInfo = {}; // loaded_key -> {loaded: N, max: N}
async function refreshLoadedStatus(){ async function refreshLoadedStatus(){
try{ try{
const r = await fetch('/admin/api/model-loaded-status'); const r = await fetch(ROOT_PATH + '/admin/api/model-loaded-status');
if(r.ok){ if(r.ok){
const d = await r.json(); const d = await r.json();
_loadedKeys = new Set(d.loaded||[]); _loadedKeys = new Set(d.loaded||[]);
...@@ -1460,7 +1509,7 @@ async function clearCacheConfirm(type){ ...@@ -1460,7 +1509,7 @@ async function clearCacheConfirm(type){
const labels = {hf:'HuggingFace', gguf:'GGUF', all:'ALL'}; const labels = {hf:'HuggingFace', gguf:'GGUF', all:'ALL'};
if(!confirm(`Delete ${labels[type]} model cache? This cannot be undone.`)) return; if(!confirm(`Delete ${labels[type]} model cache? This cannot be undone.`)) return;
try{ try{
const r = await fetch('/admin/api/cache?cache_type='+type, {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/cache?cache_type='+type, {method:'DELETE'});
const d = await r.json(); const d = await r.json();
if(d.success){ if(d.success){
refreshLocal(); refreshLocal();
...@@ -1475,7 +1524,7 @@ async function deleteModelConfirm(idx){ ...@@ -1475,7 +1524,7 @@ async function deleteModelConfirm(idx){
// Always send the full path so the backend can locate the file regardless of cache location // Always send the full path so the backend can locate the file regardless of cache location
const idForUrl = m.path; const idForUrl = m.path;
try{ try{
const r = await fetch('/admin/api/cached-models/'+encodeURIComponent(idForUrl)+'?cache_type='+m.cacheType, {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/cached-models/'+encodeURIComponent(idForUrl)+'?cache_type='+m.cacheType, {method:'DELETE'});
const d = await r.json(); const d = await r.json();
if(d.success) refreshLocal(); if(d.success) refreshLocal();
else alert('Error: '+(d.detail||'Unknown')); else alert('Error: '+(d.detail||'Unknown'));
...@@ -1500,11 +1549,16 @@ function _capabilitiesToTypes(caps) { ...@@ -1500,11 +1549,16 @@ function _capabilitiesToTypes(caps) {
if (caps.includes('video_generation')){ categories.add('video_models'); subs.add('t2v'); } if (caps.includes('video_generation')){ categories.add('video_models'); subs.add('t2v'); }
if (caps.includes('video_to_video')) { categories.add('video_models'); subs.add('v2v'); } if (caps.includes('video_to_video')) { categories.add('video_models'); subs.add('v2v'); }
if (caps.includes('image_generation') || caps.includes('image_to_image') || if (caps.includes('image_generation') || caps.includes('image_to_image') ||
caps.includes('inpainting') || caps.includes('controlnet')) categories.add('image_models'); caps.includes('inpainting') || caps.includes('controlnet') ||
caps.includes('image_upscaling') || caps.includes('face_restoration') ||
caps.includes('style_transfer')) categories.add('image_models');
if (caps.includes('depth_estimation') || caps.includes('image_segmentation') ||
caps.includes('object_detection')) categories.add('spatial_models');
if (caps.includes('image_to_text') && caps.includes('text_generation')) { if (caps.includes('image_to_text') && caps.includes('text_generation')) {
categories.add('vision_models'); categories.add('vision_models');
} else if (caps.includes('text_generation') && } else if (caps.includes('text_generation') &&
!categories.has('video_models') && !categories.has('image_models')) { !categories.has('video_models') && !categories.has('image_models') &&
!categories.has('spatial_models')) {
categories.add('text_models'); categories.add('text_models');
} }
if (caps.includes('speech_to_text')) categories.add('audio_models'); if (caps.includes('speech_to_text')) categories.add('audio_models');
...@@ -1606,7 +1660,7 @@ async function saveWhisperServerEdit() { ...@@ -1606,7 +1660,7 @@ async function saveWhisperServerEdit() {
alias: document.getElementById('wse-alias').value.trim() || null, alias: document.getElementById('wse-alias').value.trim() || null,
}; };
try { try {
const r = await fetch('/admin/api/model-configure', { const r = await fetch(ROOT_PATH + '/admin/api/model-configure', {
method: 'POST', headers: {'Content-Type': 'application/json'}, method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload) body: JSON.stringify(payload)
}); });
...@@ -1684,6 +1738,13 @@ function openCfgModal(idx){ ...@@ -1684,6 +1738,13 @@ function openCfgModal(idx){
} }
} }
// Capabilities checkboxes: saved config > auto-detected from model
const capsdet = document.getElementById('cfg-caps-autodet');
const savedCaps = new Set(s.capabilities && s.capabilities.length ? s.capabilities : (m.capabilities || []));
const capsFromConfig = !!(s.capabilities && s.capabilities.length);
document.querySelectorAll('.cfg-cap-cb').forEach(cb => { cb.checked = savedCaps.has(cb.value); });
capsdet.textContent = capsFromConfig ? '' : '(auto-detected)';
document.getElementById('cfg-alias').value = s.alias || ''; document.getElementById('cfg-alias').value = s.alias || '';
document.getElementById('cfg-backend').value = s.backend || 'auto'; document.getElementById('cfg-backend').value = s.backend || 'auto';
document.getElementById('cfg-load-mode').value = s.load_mode || 'on-request'; document.getElementById('cfg-load-mode').value = s.load_mode || 'on-request';
...@@ -1774,9 +1835,10 @@ async function saveModelConfig(){ ...@@ -1774,9 +1835,10 @@ async function saveModelConfig(){
parser: document.getElementById('cfg-parser').value, parser: document.getElementById('cfg-parser').value,
tools_closer_prompt: document.getElementById('cfg-tools').checked, tools_closer_prompt: document.getElementById('cfg-tools').checked,
grammar_guided: document.getElementById('cfg-grammar').checked, grammar_guided: document.getElementById('cfg-grammar').checked,
capabilities: [...document.querySelectorAll('.cfg-cap-cb:checked')].map(cb => cb.value),
}; };
try{ try{
const r = await fetch('/admin/api/model-configure',{ const r = await fetch(ROOT_PATH + '/admin/api/model-configure',{
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
...@@ -1803,7 +1865,7 @@ async function addWhisperServerModel(){ ...@@ -1803,7 +1865,7 @@ async function addWhisperServerModel(){
alias: document.getElementById('ws-alias').value.trim() || null, alias: document.getElementById('ws-alias').value.trim() || null,
}; };
try{ try{
const r = await fetch('/admin/api/model-configure', { const r = await fetch(ROOT_PATH + '/admin/api/model-configure', {
method:'POST', method:'POST',
headers:{'Content-Type':'application/json'}, headers:{'Content-Type':'application/json'},
body: JSON.stringify(payload) body: JSON.stringify(payload)
...@@ -1834,7 +1896,7 @@ async function loadModel(idx){ ...@@ -1834,7 +1896,7 @@ async function loadModel(idx){
const btn = document.querySelector(`button[onclick="loadModel(${idx})"]`); const btn = document.querySelector(`button[onclick="loadModel(${idx})"]`);
if(btn){ btn.disabled = true; btn.textContent = 'Loading…'; } if(btn){ btn.disabled = true; btn.textContent = 'Loading…'; }
try{ try{
const r = await fetch('/admin/api/model-load',{ const r = await fetch(ROOT_PATH + '/admin/api/model-load',{
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({path: m.path}) body: JSON.stringify({path: m.path})
}); });
...@@ -1847,7 +1909,7 @@ async function loadModel(idx){ ...@@ -1847,7 +1909,7 @@ async function loadModel(idx){
async function unloadModel(idx){ async function unloadModel(idx){
const m = _localModels[idx]; const m = _localModels[idx];
try{ try{
const r = await fetch('/admin/api/model-unload',{ const r = await fetch(ROOT_PATH + '/admin/api/model-unload',{
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({path: m.path}) body: JSON.stringify({path: m.path})
}); });
...@@ -1868,7 +1930,7 @@ async function disableModel(idx){ ...@@ -1868,7 +1930,7 @@ async function disableModel(idx){
try{ try{
// Disable each configured path in the group // Disable each configured path in the group
for(const p of paths){ for(const p of paths){
await fetch('/admin/api/model-disable',{ await fetch(ROOT_PATH + '/admin/api/model-disable',{
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({path: p}) body: JSON.stringify({path: p})
}); });
...@@ -1904,7 +1966,7 @@ async function startUpload(){ ...@@ -1904,7 +1966,7 @@ async function startUpload(){
formData.append('upload_id', uploadId); formData.append('upload_id', uploadId);
try{ try{
const r = await fetch('/admin/api/model-upload',{method:'POST', body:formData}); const r = await fetch(ROOT_PATH + '/admin/api/model-upload',{method:'POST', body:formData});
const d = await r.json(); const d = await r.json();
if(!d.success){ alert('Upload failed: '+(d.detail||'Unknown')); return; } if(!d.success){ alert('Upload failed: '+(d.detail||'Unknown')); return; }
......
...@@ -120,7 +120,7 @@ function showAlert(type, msg){ ...@@ -120,7 +120,7 @@ function showAlert(type, msg){
async function loadSettings(){ async function loadSettings(){
try{ try{
const d = await fetch('/admin/api/settings').then(r=>r.json()); const d = await fetch(ROOT_PATH + '/admin/api/settings').then(r=>r.json());
document.getElementById('s-host').value = d.server?.host ?? '0.0.0.0'; document.getElementById('s-host').value = d.server?.host ?? '0.0.0.0';
document.getElementById('s-port').value = d.server?.port ?? 8000; document.getElementById('s-port').value = d.server?.port ?? 8000;
document.getElementById('s-https').checked = !!d.server?.https; document.getElementById('s-https').checked = !!d.server?.https;
...@@ -138,7 +138,7 @@ async function loadSettings(){ ...@@ -138,7 +138,7 @@ async function loadSettings(){
document.getElementById('s-arc-retention').value = arc.retention ?? 'never'; document.getElementById('s-arc-retention').value = arc.retention ?? 'never';
// Show effective default dir as placeholder hint // Show effective default dir as placeholder hint
try { try {
const as = await fetch('/admin/api/archive-settings').then(r=>r.json()); const as = await fetch(ROOT_PATH + '/admin/api/archive-settings').then(r=>r.json());
const hint = document.getElementById('s-arc-dir-hint'); const hint = document.getElementById('s-arc-dir-hint');
if (hint && as.default_directory) hint.textContent = `(default: ${as.default_directory})`; if (hint && as.default_directory) hint.textContent = `(default: ${as.default_directory})`;
document.getElementById('s-arc-dir').placeholder = as.default_directory || '(default)'; document.getElementById('s-arc-dir').placeholder = as.default_directory || '(default)';
...@@ -171,7 +171,7 @@ async function saveSettings(){ ...@@ -171,7 +171,7 @@ async function saveSettings(){
}, },
}; };
try{ try{
const r = await fetch('/admin/api/settings',{ const r = await fetch(ROOT_PATH + '/admin/api/settings',{
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data) body: JSON.stringify(data)
}); });
......
...@@ -93,7 +93,7 @@ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').re ...@@ -93,7 +93,7 @@ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').re
async function loadTokens() { async function loadTokens() {
try { try {
const tokens = await fetch('/admin/api/tokens').then(r => r.json()); const tokens = await fetch(ROOT_PATH + '/admin/api/tokens').then(r => r.json());
const tbody = document.getElementById('tokens-body'); const tbody = document.getElementById('tokens-body');
if (!tokens.length) { if (!tokens.length) {
tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No tokens — create one to get started</td></tr>'; tbody.innerHTML = '<tr class="empty-row"><td colspan="6">No tokens — create one to get started</td></tr>';
...@@ -115,7 +115,7 @@ async function createToken() { ...@@ -115,7 +115,7 @@ async function createToken() {
const name = document.getElementById('t-name').value.trim(); const name = document.getElementById('t-name').value.trim();
if (!name) { document.getElementById('t-name').focus(); return; } if (!name) { document.getElementById('t-name').focus(); return; }
try { try {
const r = await fetch('/admin/api/tokens', { const r = await fetch(ROOT_PATH + '/admin/api/tokens', {
method:'POST', headers:{'Content-Type':'application/json'}, method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({name, provider: document.getElementById('t-provider').value}) body: JSON.stringify({name, provider: document.getElementById('t-provider').value})
}); });
...@@ -133,7 +133,7 @@ async function createToken() { ...@@ -133,7 +133,7 @@ async function createToken() {
async function delToken(id) { async function delToken(id) {
if (!confirm('Delete this token? Clients using it will lose access immediately.')) return; if (!confirm('Delete this token? Clients using it will lose access immediately.')) return;
const r = await fetch('/admin/api/tokens/'+id, {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/tokens/'+id, {method:'DELETE'});
if (r.ok) loadTokens(); else alert('Failed to delete'); if (r.ok) loadTokens(); else alert('Failed to delete');
} }
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
<td class="mono small dim">{{ user.created_at[:10] }}</td> <td class="mono small dim">{{ user.created_at[:10] }}</td>
<td style="text-align:right"> <td style="text-align:right">
{% if user.username == username %} {% if user.username == username %}
<a href="/admin/change-password" class="btn btn-ghost btn-sm">Change password</a> <a href="{{ root_path }}/admin/change-password" class="btn btn-ghost btn-sm">Change password</a>
{% else %} {% else %}
<button class="btn btn-danger btn-sm" onclick="delUser({{ user.id }}, '{{ user.username }}')">Delete</button> <button class="btn btn-danger btn-sm" onclick="delUser({{ user.id }}, '{{ user.username }}')">Delete</button>
{% endif %} {% endif %}
...@@ -92,7 +92,7 @@ async function addUser() { ...@@ -92,7 +92,7 @@ async function addUser() {
if (!uname) { errEl.textContent = 'Username required'; errEl.style.display = 'flex'; return; } if (!uname) { errEl.textContent = 'Username required'; errEl.style.display = 'flex'; return; }
if (pwd.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = 'flex'; return; } if (pwd.length < 8) { errEl.textContent = 'Password must be at least 8 characters'; errEl.style.display = 'flex'; return; }
try { try {
const r = await fetch('/admin/api/users', { const r = await fetch(ROOT_PATH + '/admin/api/users', {
method: 'POST', headers: {'Content-Type':'application/json'}, method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({username: uname, password: pwd, role: document.getElementById('new-role').value}) body: JSON.stringify({username: uname, password: pwd, role: document.getElementById('new-role').value})
}); });
...@@ -103,7 +103,7 @@ async function addUser() { ...@@ -103,7 +103,7 @@ async function addUser() {
async function delUser(id, name) { async function delUser(id, name) {
if (!confirm('Delete user "' + name + '"?')) return; if (!confirm('Delete user "' + name + '"?')) return;
const r = await fetch('/admin/api/users/'+id, {method:'DELETE'}); const r = await fetch(ROOT_PATH + '/admin/api/users/'+id, {method:'DELETE'});
if (r.ok) location.reload(); if (r.ok) location.reload();
else { const e = await r.json(); alert(e.detail || 'Failed'); } else { const e = await r.json(); alert(e.detail || 'Failed'); }
} }
......
...@@ -137,6 +137,36 @@ app.middleware("http")(log_requests) ...@@ -137,6 +137,36 @@ app.middleware("http")(log_requests)
app.add_middleware(RateLimitMiddleware) app.add_middleware(RateLimitMiddleware)
app.add_middleware(BearerAuthMiddleware) app.add_middleware(BearerAuthMiddleware)
# Reverse-proxy support: update ASGI scope with forwarded headers so that
# request.url, redirects, and url_for() reflect the public-facing URL.
try:
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
except ImportError:
pass
class _ForwardedPrefixMiddleware:
"""Populate ASGI root_path from X-Forwarded-Prefix / X-Script-Name headers."""
def __init__(self, app):
self._app = app
async def __call__(self, scope, receive, send):
if scope["type"] in ("http", "websocket"):
headers = dict(scope.get("headers", []))
prefix = (
headers.get(b"x-forwarded-prefix", b"")
or headers.get(b"x-script-name", b"")
)
if prefix:
scope = dict(scope)
scope["root_path"] = prefix.decode().rstrip("/")
await self._app(scope, receive, send)
app.add_middleware(_ForwardedPrefixMiddleware)
# Mount static files for admin dashboard # Mount static files for admin dashboard
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pathlib import Path from pathlib import Path
...@@ -171,7 +201,9 @@ async def unauthorized_redirect(request: Request, exc: HTTPException): ...@@ -171,7 +201,9 @@ async def unauthorized_redirect(request: Request, exc: HTTPException):
accept = request.headers.get("accept", "") accept = request.headers.get("accept", "")
if "text/html" in accept: if "text/html" in accept:
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
return RedirectResponse(url="/login", status_code=302) from codai.api.urlutils import get_public_prefix
prefix = get_public_prefix(request)
return RedirectResponse(url=f"{prefix}/login", status_code=302)
return JSONResponse(status_code=401, content={"detail": exc.detail}) return JSONResponse(status_code=401, content={"detail": exc.detail})
...@@ -204,10 +236,11 @@ _AUDIO_EXTS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a'} ...@@ -204,10 +236,11 @@ _AUDIO_EXTS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a'}
@app.get("/v1/archive") @app.get("/v1/archive")
async def list_archive(): async def list_archive(request: Request):
"""List all generated files in the output directory.""" """List all generated files in the output directory."""
if not global_file_path or not os.path.isdir(global_file_path): if not global_file_path or not os.path.isdir(global_file_path):
return {"files": []} return {"files": []}
from codai.api.urlutils import build_file_url
files = [] files = []
try: try:
names = os.listdir(global_file_path) names = os.listdir(global_file_path)
...@@ -232,7 +265,7 @@ async def list_archive(): ...@@ -232,7 +265,7 @@ async def list_archive():
'type': ftype, 'type': ftype,
'size': stat.st_size, 'size': stat.st_size,
'created': stat.st_mtime, 'created': stat.st_mtime,
'url': f'/v1/files/{fname}', 'url': build_file_url(fname, request),
}) })
files.sort(key=lambda f: f['created'], reverse=True) files.sort(key=lambda f: f['created'], reverse=True)
return {"files": files} return {"files": files}
......
...@@ -43,21 +43,8 @@ def _ffmpeg_binary() -> str: ...@@ -43,21 +43,8 @@ def _ffmpeg_binary() -> str:
return ffmpeg return ffmpeg
def _base_url(http_request: Request) -> str:
url_setting = getattr(global_args, "url", "auto") if global_args else "auto"
if url_setting != "auto":
return url_setting.rstrip("/")
host = http_request.headers.get("host", "127.0.0.1") if http_request else "127.0.0.1"
if ":" in host:
parts = host.split(":")
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
proto = "https" if getattr(global_args, "https", False) else "http"
port = getattr(global_args, "port", 8000) if global_args else 8000
return f"{proto}://{host}:{port}"
def _persist_file(path: str, suffix: str, http_request: Request) -> dict: def _persist_file(path: str, suffix: str, http_request: Request) -> dict:
from codai.api.urlutils import build_file_url
data = Path(path).read_bytes() data = Path(path).read_bytes()
if global_file_path: if global_file_path:
os.makedirs(global_file_path, exist_ok=True) os.makedirs(global_file_path, exist_ok=True)
...@@ -65,7 +52,7 @@ def _persist_file(path: str, suffix: str, http_request: Request) -> dict: ...@@ -65,7 +52,7 @@ def _persist_file(path: str, suffix: str, http_request: Request) -> dict:
out_path = os.path.join(global_file_path, filename) out_path = os.path.join(global_file_path, filename)
with open(out_path, "wb") as handle: with open(out_path, "wb") as handle:
handle.write(data) handle.write(data)
return {"url": f"{_base_url(http_request)}/v1/files/{filename}"} return {"url": build_file_url(filename, http_request)}
return {f"b64_{suffix.lstrip('.')}": base64.b64encode(data).decode("ascii")} return {f"b64_{suffix.lstrip('.')}": base64.b64encode(data).decode("ascii")}
......
...@@ -89,20 +89,8 @@ def _save_audio_response(audio_data: bytes, ext: str, http_request: Request) -> ...@@ -89,20 +89,8 @@ def _save_audio_response(audio_data: bytes, ext: str, http_request: Request) ->
fpath = os.path.join(global_file_path, filename) fpath = os.path.join(global_file_path, filename)
with open(fpath, 'wb') as f: with open(fpath, 'wb') as f:
f.write(audio_data) f.write(audio_data)
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto' from codai.api.urlutils import build_file_url
if url_setting == 'auto': return {"url": build_file_url(filename, http_request)}
host = http_request.headers.get('host', '127.0.0.1') if http_request else '127.0.0.1'
if ':' in host:
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
use_https = getattr(global_args, 'https', False) if global_args else False
proto = 'https' if use_https else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
base_url = f"{proto}://{host}:{port}"
else:
base_url = url_setting.rstrip('/')
return {"url": f"{base_url}/v1/files/{filename}"}
else: else:
b64 = base64.b64encode(audio_data).decode() b64 = base64.b64encode(audio_data).decode()
return {f"b64_{ext}": b64} return {f"b64_{ext}": b64}
......
...@@ -43,21 +43,8 @@ def _ffmpeg_binary() -> str: ...@@ -43,21 +43,8 @@ def _ffmpeg_binary() -> str:
return ffmpeg return ffmpeg
def _base_url(http_request: Request) -> str:
url_setting = getattr(global_args, "url", "auto") if global_args else "auto"
if url_setting != "auto":
return url_setting.rstrip("/")
host = http_request.headers.get("host", "127.0.0.1") if http_request else "127.0.0.1"
if ":" in host:
parts = host.split(":")
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
proto = "https" if getattr(global_args, "https", False) else "http"
port = getattr(global_args, "port", 8000) if global_args else 8000
return f"{proto}://{host}:{port}"
def _persist_file(path: str, suffix: str, http_request: Request) -> dict: def _persist_file(path: str, suffix: str, http_request: Request) -> dict:
from codai.api.urlutils import build_file_url
data = Path(path).read_bytes() data = Path(path).read_bytes()
if global_file_path: if global_file_path:
os.makedirs(global_file_path, exist_ok=True) os.makedirs(global_file_path, exist_ok=True)
...@@ -65,7 +52,7 @@ def _persist_file(path: str, suffix: str, http_request: Request) -> dict: ...@@ -65,7 +52,7 @@ def _persist_file(path: str, suffix: str, http_request: Request) -> dict:
out_path = os.path.join(global_file_path, filename) out_path = os.path.join(global_file_path, filename)
with open(out_path, "wb") as handle: with open(out_path, "wb") as handle:
handle.write(data) handle.write(data)
return {"url": f"{_base_url(http_request)}/v1/files/{filename}"} return {"url": build_file_url(filename, http_request)}
return {f"b64_{suffix.lstrip('.')}": base64.b64encode(data).decode("ascii")} return {f"b64_{suffix.lstrip('.')}": base64.b64encode(data).decode("ascii")}
......
...@@ -251,14 +251,8 @@ async def _faceswap_video(src_img, request, http_request): ...@@ -251,14 +251,8 @@ async def _faceswap_video(src_img, request, http_request):
os.makedirs(global_file_path, exist_ok=True) os.makedirs(global_file_path, exist_ok=True)
with open(fpath, 'wb') as f: with open(fpath, 'wb') as f:
f.write(out_bytes) f.write(out_bytes)
host = http_request.headers.get('host', '127.0.0.1') if http_request else '127.0.0.1' from codai.api.urlutils import build_file_url
if ':' in host: data = [{'url': build_file_url(fname, http_request)}]
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
proto = 'https' if getattr(global_args, 'https', False) else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
data = [{'url': f'{proto}://{host}:{port}/v1/files/{fname}'}]
else: else:
data = [{'b64_mp4': base64.b64encode(out_bytes).decode()}] data = [{'b64_mp4': base64.b64encode(out_bytes).decode()}]
......
...@@ -211,42 +211,8 @@ def save_image_response(img, request_format="base64", http_request=None): ...@@ -211,42 +211,8 @@ def save_image_response(img, request_format="base64", http_request=None):
file_path = os.path.join(global_file_path, filename) file_path = os.path.join(global_file_path, filename)
img.save(file_path, format="PNG") img.save(file_path, format="PNG")
# Add URL to response # Add URL to response
# Determine base URL based on --url argument from codai.api.urlutils import build_file_url
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto' result["url"] = build_file_url(filename, http_request)
if url_setting == 'auto':
# Use server host from request headers (what client used to connect)
if http_request:
# Get the Host header - this is what the client used to reach the server
# The Host header typically includes the port, e.g., "192.168.1.1:6745"
client_host = http_request.headers.get('host', '')
if not client_host:
# Fallback to client IP if no Host header
client_host = http_request.client.host if http_request.client else '127.0.0.1'
# Strip port from host if present (Host header includes port like "192.168.1.1:6745")
if ':' in client_host:
# Check if the part after : is a port number
parts = client_host.split(':')
if len(parts) == 2 and parts[1].isdigit():
# It's a port number, strip it
client_host = parts[0]
elif len(parts) > 2:
# IPv6 or other format, take last part as port check
last_part = parts[-1]
if last_part.isdigit():
client_host = ':'.join(parts[:-1])
# Check if HTTPS is enabled
use_https = getattr(global_args, 'https', False) or getattr(global_args, 'pubkey', None)
protocol = "https" if use_https else "http"
port = getattr(global_args, 'port', 8000)
base_url = f"{protocol}://{client_host}:{port}"
else:
base_url = "http://127.0.0.1:8000"
else:
# Use explicitly provided URL (strip trailing slash if present)
base_url = url_setting.rstrip('/')
result["url"] = f"{base_url}/v1/files/{filename}"
# If client explicitly requested base64, include it # If client explicitly requested base64, include it
# Otherwise, only return URL when file-path is set # Otherwise, only return URL when file-path is set
...@@ -1474,7 +1440,7 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque ...@@ -1474,7 +1440,7 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque
# ============================================================================= # =============================================================================
class ImageDepthRequest(BaseModel): class ImageDepthRequest(BaseModel):
model: str model: Optional[str] = None
image: str image: str
response_format: Optional[str] = "url" response_format: Optional[str] = "url"
class Config: class Config:
...@@ -1522,11 +1488,39 @@ def _run_depth(depth_model, image_bytes: bytes): ...@@ -1522,11 +1488,39 @@ def _run_depth(depth_model, image_bytes: bytes):
return PILImage.fromarray(depth_arr) return PILImage.fromarray(depth_arr)
def _resolve_spatial_model(requested: Optional[str], capability: str) -> Optional[str]:
"""Return a configured spatial model for the given capability, or the requested ID."""
if requested:
return requested
try:
from codai.admin.routes import config_manager
if config_manager is not None:
for entry in config_manager.models_data.get("spatial_models", []):
if isinstance(entry, dict):
saved_caps = entry.get("capabilities") or []
mid = entry.get("path") or entry.get("id") or ""
if capability in saved_caps and mid:
return mid
# Fall back to runtime list using name heuristic
from codai.models.capabilities import detect_model_capabilities
for m in multi_model_manager.spatial_models:
caps = detect_model_capabilities(m)
if getattr(caps, capability, False):
return m
if multi_model_manager.spatial_models:
return multi_model_manager.spatial_models[0]
except Exception:
pass
return None
@router.post("/v1/images/depth") @router.post("/v1/images/depth")
async def create_image_depth(request: ImageDepthRequest, http_request: Request = None): async def create_image_depth(request: ImageDepthRequest, http_request: Request = None):
"""Estimate depth map from an image.""" """Estimate depth map from an image."""
global global_args global global_args
model_name = request.model model_name = _resolve_spatial_model(request.model, "depth_estimation")
if not model_name:
raise HTTPException(status_code=400, detail="No depth estimation model configured. Add one via Admin > Models.")
model_key = f"depth:{model_name}" model_key = f"depth:{model_name}"
depth_model = multi_model_manager.models.get(model_key) depth_model = multi_model_manager.models.get(model_key)
if depth_model is None: if depth_model is None:
...@@ -1551,7 +1545,7 @@ async def create_image_depth(request: ImageDepthRequest, http_request: Request = ...@@ -1551,7 +1545,7 @@ async def create_image_depth(request: ImageDepthRequest, http_request: Request =
# ============================================================================= # =============================================================================
class ImageSegmentRequest(BaseModel): class ImageSegmentRequest(BaseModel):
model: str model: Optional[str] = None
image: str image: str
points: Optional[list] = None # [[x,y], ...] positive prompt points for SAM points: Optional[list] = None # [[x,y], ...] positive prompt points for SAM
boxes: Optional[list] = None # [[x1,y1,x2,y2], ...] box prompts boxes: Optional[list] = None # [[x1,y1,x2,y2], ...] box prompts
...@@ -1619,7 +1613,9 @@ def _run_segmentation(seg_model, image_bytes: bytes, points, boxes): ...@@ -1619,7 +1613,9 @@ def _run_segmentation(seg_model, image_bytes: bytes, points, boxes):
async def create_image_segment(request: ImageSegmentRequest, http_request: Request = None): async def create_image_segment(request: ImageSegmentRequest, http_request: Request = None):
"""Segment objects in an image using SAM or similar models.""" """Segment objects in an image using SAM or similar models."""
global global_args global global_args
model_name = request.model model_name = _resolve_spatial_model(request.model, "image_segmentation")
if not model_name:
raise HTTPException(status_code=400, detail="No segmentation model configured. Add one via Admin > Models.")
model_key = f"segment:{model_name}" model_key = f"segment:{model_name}"
seg_model = multi_model_manager.models.get(model_key) seg_model = multi_model_manager.models.get(model_key)
if seg_model is None: if seg_model is None:
...@@ -1953,14 +1949,8 @@ async def _outfit_video(request: ImageOutfitRequest, http_request): ...@@ -1953,14 +1949,8 @@ async def _outfit_video(request: ImageOutfitRequest, http_request):
os.makedirs(global_file_path, exist_ok=True) os.makedirs(global_file_path, exist_ok=True)
with open(fpath_out, 'wb') as f: with open(fpath_out, 'wb') as f:
f.write(out_bytes) f.write(out_bytes)
host = http_request.headers.get('host', '127.0.0.1') if http_request else '127.0.0.1' from codai.api.urlutils import build_file_url
if ':' in host: data = [{'url': build_file_url(fname, http_request)}]
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
proto = 'https' if getattr(global_args, 'https', False) else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
data = [{'url': f'{proto}://{host}:{port}/v1/files/{fname}'}]
else: else:
data = [{'b64_mp4': base64.b64encode(out_bytes).decode()}] data = [{'b64_mp4': base64.b64encode(out_bytes).decode()}]
......
...@@ -67,18 +67,8 @@ def _decode_b64(data: str) -> bytes: ...@@ -67,18 +67,8 @@ def _decode_b64(data: str) -> bytes:
def _build_url(filename: str, http_request) -> str: def _build_url(filename: str, http_request) -> str:
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto' from codai.api.urlutils import build_file_url
if url_setting == 'auto': return build_file_url(filename, http_request)
host = (http_request.headers.get('host', '127.0.0.1') if http_request else '127.0.0.1')
if ':' in host:
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
use_https = getattr(global_args, 'https', False) or getattr(global_args, 'pubkey', None)
proto = 'https' if use_https else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
return f"{proto}://{host}:{port}/v1/files/{filename}"
return f"{url_setting.rstrip('/')}/v1/files/{filename}"
def _save_output(data: bytes, ext: str, http_request) -> dict: def _save_output(data: bytes, ext: str, http_request) -> dict:
...@@ -111,14 +101,26 @@ def _img_to_bytes(img_pil, fmt: str = "PNG") -> bytes: ...@@ -111,14 +101,26 @@ def _img_to_bytes(img_pil, fmt: str = "PNG") -> bytes:
# Depth estimation # Depth estimation
# ============================================================================= # =============================================================================
def _configured_depth_model_id() -> str:
"""Return the configured spatial/depth model ID, or empty string if none."""
try:
from codai.models.manager import multi_model_manager
if multi_model_manager.spatial_models:
return multi_model_manager.spatial_models[0]
except Exception:
pass
return ""
def _estimate_depth(img_pil) -> np.ndarray: def _estimate_depth(img_pil) -> np.ndarray:
"""Return depth array normalised to 0–1 (float32). Higher = closer.""" """Return depth array normalised to 0–1 (float32). Higher = closer."""
device = _derive_device() device = _derive_device()
# Prefer transformers depth-estimation pipeline # Prefer transformers depth-estimation pipeline (use configured model if available)
try: try:
from transformers import pipeline as hf_pipeline from transformers import pipeline as hf_pipeline
pipe = hf_pipeline("depth-estimation", device=device) model_id = _configured_depth_model_id() or None
pipe = hf_pipeline("depth-estimation", model=model_id, device=device)
result = pipe(img_pil) result = pipe(img_pil)
depth = np.array(result['depth'], dtype=np.float32) depth = np.array(result['depth'], dtype=np.float32)
d_min, d_max = depth.min(), depth.max() d_min, d_max = depth.min(), depth.max()
......
# CoderAI - OpenAI-compatible API server
# Copyright (C) 2026 Stefy Lanza <stefy@nexlab.net>
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
"""Proxy-aware URL utilities.
Consults standard reverse-proxy headers (X-Forwarded-Proto, X-Forwarded-Host,
X-Forwarded-Port, X-Forwarded-Prefix / X-Script-Name) to build the correct
public-facing base URL for file references and redirects.
Priority order when url_setting == 'auto':
1. X-Forwarded-Host — overrides host (and implicitly port)
2. X-Forwarded-Proto — overrides scheme
3. X-Forwarded-Port — overrides port when not already in the host
4. X-Forwarded-Prefix / X-Script-Name / ASGI root_path — path prefix
5. Host header + configured global_args.port + global_args.https as fallback
"""
def get_public_prefix(request) -> str:
"""Return the path prefix string (no trailing slash), e.g. '/coderai'."""
if request is None:
return ""
prefix = (
request.headers.get("x-forwarded-prefix")
or request.headers.get("x-script-name")
or request.scope.get("root_path", "")
or ""
)
return prefix.rstrip("/")
def get_base_url(request) -> str:
"""Return the public-facing base URL (no trailing slash).
When global_args.url is set to anything other than 'auto', that value is
returned verbatim (allowing administrators to pin a specific URL).
"""
from codai.api.state import get_global_args
global_args = get_global_args()
url_setting = getattr(global_args, "url", "auto") if global_args else "auto"
if url_setting and url_setting != "auto":
return url_setting.rstrip("/")
if request is None:
port = getattr(global_args, "port", 8000) if global_args else 8000
return f"http://127.0.0.1:{port}"
headers = request.headers
fwd_proto = headers.get("x-forwarded-proto") or headers.get("x-scheme")
fwd_host = headers.get("x-forwarded-host")
fwd_port = headers.get("x-forwarded-port")
prefix = get_public_prefix(request)
if fwd_host:
# Forwarded host may already carry a port (e.g. "example.com:8443")
if ":" not in fwd_host and fwd_port:
host_part = f"{fwd_host}:{fwd_port}"
else:
host_part = fwd_host
proto = fwd_proto or "https"
# Strip default ports so URLs stay clean
if proto == "https" and host_part.endswith(":443"):
host_part = host_part[:-4]
elif proto == "http" and host_part.endswith(":80"):
host_part = host_part[:-3]
return f"{proto}://{host_part}{prefix}"
# No X-Forwarded-Host — fall back to Host header
host = headers.get("host", "")
if not host:
host = request.client.host if request.client else "127.0.0.1"
# If Host header already carries a port, honour it; otherwise append the
# configured server port so the URL is always absolute and unambiguous.
if ":" not in host:
port = getattr(global_args, "port", 8000) if global_args else 8000
if fwd_port:
host = f"{host}:{fwd_port}"
else:
host = f"{host}:{port}"
use_https = (
getattr(global_args, "https", False) or getattr(global_args, "pubkey", None)
)
proto = fwd_proto or ("https" if use_https else "http")
# Strip default ports
if proto == "https" and host.endswith(":443"):
host = host[:-4]
elif proto == "http" and host.endswith(":80"):
host = host[:-3]
return f"{proto}://{host}{prefix}"
def build_file_url(filename: str, request) -> str:
"""Return the public URL for a generated file served via /v1/files/{filename}."""
return f"{get_base_url(request)}/v1/files/{filename}"
...@@ -119,21 +119,8 @@ def _pil_from_b64(data: str): ...@@ -119,21 +119,8 @@ def _pil_from_b64(data: str):
def _build_url(filename: str, http_request) -> str: def _build_url(filename: str, http_request) -> str:
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto' from codai.api.urlutils import build_file_url
if url_setting == 'auto': return build_file_url(filename, http_request)
host = (http_request.headers.get('host', '127.0.0.1')
if http_request else '127.0.0.1')
if ':' in host:
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
use_https = getattr(global_args, 'https', False) or getattr(global_args, 'pubkey', None)
proto = 'https' if use_https else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
base_url = f"{proto}://{host}:{port}"
else:
base_url = url_setting.rstrip('/')
return f"{base_url}/v1/files/{filename}"
def _save_file(data: bytes, ext: str, http_request) -> dict: def _save_file(data: bytes, ext: str, http_request) -> dict:
......
...@@ -154,15 +154,8 @@ def _save_audio_response(audio_bytes: bytes, http_request: Request) -> dict: ...@@ -154,15 +154,8 @@ def _save_audio_response(audio_bytes: bytes, http_request: Request) -> dict:
fpath = os.path.join(global_file_path, filename) fpath = os.path.join(global_file_path, filename)
with open(fpath, 'wb') as f: with open(fpath, 'wb') as f:
f.write(audio_bytes) f.write(audio_bytes)
host = http_request.headers.get('host', '127.0.0.1') if http_request else '127.0.0.1' from codai.api.urlutils import build_file_url
if ':' in host: return {"url": build_file_url(filename, http_request)}
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
use_https = getattr(global_args, 'https', False) if global_args else False
proto = 'https' if use_https else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
return {"url": f"{proto}://{host}:{port}/v1/files/{filename}"}
return {"b64_wav": base64.b64encode(audio_bytes).decode()} return {"b64_wav": base64.b64encode(audio_bytes).decode()}
......
...@@ -67,14 +67,8 @@ def _save_response(audio_np: np.ndarray, sr: int, http_request) -> dict: ...@@ -67,14 +67,8 @@ def _save_response(audio_np: np.ndarray, sr: int, http_request) -> dict:
fpath = os.path.join(global_file_path, filename) fpath = os.path.join(global_file_path, filename)
with open(fpath, 'wb') as f: with open(fpath, 'wb') as f:
f.write(wav_bytes) f.write(wav_bytes)
host = http_request.headers.get('host', '127.0.0.1') if http_request else '127.0.0.1' from codai.api.urlutils import build_file_url
if ':' in host: return {'url': build_file_url(filename, http_request)}
parts = host.split(':')
if len(parts) == 2 and parts[1].isdigit():
host = parts[0]
proto = 'https' if getattr(global_args, 'https', False) else 'http'
port = getattr(global_args, 'port', 8000) if global_args else 8000
return {'url': f'{proto}://{host}:{port}/v1/files/{filename}'}
return {'b64_wav': base64.b64encode(wav_bytes).decode()} return {'b64_wav': base64.b64encode(wav_bytes).decode()}
......
...@@ -463,6 +463,15 @@ def main(): ...@@ -463,6 +463,15 @@ def main():
if isinstance(m, dict) and m.get("alias"): if isinstance(m, dict) and m.get("alias"):
multi_model_manager.set_model_alias(m["alias"], mid) multi_model_manager.set_model_alias(m["alias"], mid)
# Spatial models (depth estimation, segmentation, object detection)
spatial_models = models_config.get("spatial_models", [])
for m in spatial_models:
mid = _model_id(m)
if mid:
multi_model_manager.set_spatial_model(mid, config=_model_cfg(m, "spatial") if isinstance(m, dict) else {})
if isinstance(m, dict) and m.get("alias"):
multi_model_manager.set_model_alias(m["alias"], mid)
# Register aliases # Register aliases
aliases = models_config.get("aliases", {}) aliases = models_config.get("aliases", {})
for alias, model in aliases.items(): for alias, model in aliases.items():
...@@ -477,7 +486,8 @@ def main(): ...@@ -477,7 +486,8 @@ def main():
[("tts", m) for m in tts_models] + [("tts", m) for m in tts_models] +
[("video", m) for m in video_models] + [("video", m) for m in video_models] +
[("audio_gen", m) for m in audio_gen_models] + [("audio_gen", m) for m in audio_gen_models] +
[("embedding", m) for m in embedding_models] [("embedding", m) for m in embedding_models] +
[("spatial", m) for m in spatial_models]
) )
for mtype, m in all_model_entries: for mtype, m in all_model_entries:
mid = _model_id(m) mid = _model_id(m)
......
...@@ -496,6 +496,7 @@ class MultiModelManager: ...@@ -496,6 +496,7 @@ class MultiModelManager:
self.video_models: List[str] = [] # video generation models (t2v / i2v / v2v) self.video_models: List[str] = [] # video generation models (t2v / i2v / v2v)
self.audio_gen_models: List[str] = [] # music / sfx generation (MusicGen, AudioLDM2…) self.audio_gen_models: List[str] = [] # music / sfx generation (MusicGen, AudioLDM2…)
self.embedding_models: List[str] = [] # text / multimodal embeddings self.embedding_models: List[str] = [] # text / multimodal embeddings
self.spatial_models: List[str] = [] # depth estimation, segmentation, object detection
self.config: Dict[str, Dict] = {} # Store model configurations self.config: Dict[str, Dict] = {} # Store model configurations
self.tool_parser = ModelParserAdapter() self.tool_parser = ModelParserAdapter()
self.current_model_key: Optional[str] = None self.current_model_key: Optional[str] = None
...@@ -868,6 +869,13 @@ class MultiModelManager: ...@@ -868,6 +869,13 @@ class MultiModelManager:
self.config[f"embedding:{model_name}"] = config or {} self.config[f"embedding:{model_name}"] = config or {}
print(f"Registered embedding model: {model_name}") print(f"Registered embedding model: {model_name}")
def set_spatial_model(self, model_name: str, config: Dict = None):
"""Add a depth estimation / segmentation / object detection model."""
if model_name not in self.spatial_models:
self.spatial_models.append(model_name)
self.config[f"spatial:{model_name}"] = config or {}
print(f"Registered spatial model: {model_name}")
def set_model_alias(self, alias: str, model_name: str): def set_model_alias(self, alias: str, model_name: str):
"""Register an alias for a model.""" """Register an alias for a model."""
self.model_aliases[alias] = model_name self.model_aliases[alias] = model_name
...@@ -934,6 +942,13 @@ class MultiModelManager: ...@@ -934,6 +942,13 @@ class MultiModelManager:
allowed.add(m) allowed.add(m)
allowed.add(f"embedding:{m}") allowed.add(f"embedding:{m}")
# Spatial models (depth estimation, segmentation, detection)
if self.spatial_models:
allowed.add("spatial")
for m in self.spatial_models:
allowed.add(m)
allowed.add(f"spatial:{m}")
# Custom aliases # Custom aliases
for alias in self.model_aliases: for alias in self.model_aliases:
allowed.add(alias) allowed.add(alias)
...@@ -945,7 +960,8 @@ class MultiModelManager: ...@@ -945,7 +960,8 @@ class MultiModelManager:
md = config_manager.models_data md = config_manager.models_data
for cat in ("text_models", "image_models", "audio_models", for cat in ("text_models", "image_models", "audio_models",
"gguf_models", "tts_models", "vision_models", "gguf_models", "tts_models", "vision_models",
"video_models", "audio_gen_models", "embedding_models"): "video_models", "audio_gen_models", "embedding_models",
"spatial_models"):
for m in md.get(cat, []): for m in md.get(cat, []):
mid = (m if isinstance(m, str) else mid = (m if isinstance(m, str) else
m.get("alias") or m.get("path") or m.get("id") or "") m.get("alias") or m.get("path") or m.get("id") or "")
...@@ -995,6 +1011,9 @@ class MultiModelManager: ...@@ -995,6 +1011,9 @@ class MultiModelManager:
for m in self.embedding_models: for m in self.embedding_models:
if _matches(m): if _matches(m):
return "embedding" return "embedding"
for m in self.spatial_models:
if _matches(m):
return "spatial"
return None return None
def is_allowed_model(self, requested_or_resolved: str, model_type: str = None) -> bool: def is_allowed_model(self, requested_or_resolved: str, model_type: str = None) -> bool:
...@@ -1462,7 +1481,7 @@ class MultiModelManager: ...@@ -1462,7 +1481,7 @@ class MultiModelManager:
bare = model_key.split(":", 1)[1] if ":" in model_key else model_key bare = model_key.split(":", 1)[1] if ":" in model_key else model_key
for cat in ("text_models", "image_models", "audio_models", "tts_models", for cat in ("text_models", "image_models", "audio_models", "tts_models",
"vision_models", "video_models", "audio_gen_models", "vision_models", "video_models", "audio_gen_models",
"embedding_models"): "embedding_models", "spatial_models"):
for entry in config_manager.models_data.get(cat, []): for entry in config_manager.models_data.get(cat, []):
if not isinstance(entry, dict): if not isinstance(entry, dict):
continue continue
...@@ -1712,6 +1731,8 @@ class MultiModelManager: ...@@ -1712,6 +1731,8 @@ class MultiModelManager:
resolved_name = self.audio_gen_models[0] if self.audio_gen_models else None resolved_name = self.audio_gen_models[0] if self.audio_gen_models else None
elif model_type == "embedding": elif model_type == "embedding":
resolved_name = self.embedding_models[0] if self.embedding_models else None resolved_name = self.embedding_models[0] if self.embedding_models else None
elif model_type == "spatial":
resolved_name = self.spatial_models[0] if self.spatial_models else None
else: else:
resolved_name = self.default_model resolved_name = self.default_model
else: else:
...@@ -1737,6 +1758,8 @@ class MultiModelManager: ...@@ -1737,6 +1758,8 @@ class MultiModelManager:
resolved_name = self.audio_gen_models[0] if self.audio_gen_models else None resolved_name = self.audio_gen_models[0] if self.audio_gen_models else None
elif requested_model == "embedding": elif requested_model == "embedding":
resolved_name = self.embedding_models[0] if self.embedding_models else None resolved_name = self.embedding_models[0] if self.embedding_models else None
elif requested_model == "spatial":
resolved_name = self.spatial_models[0] if self.spatial_models else None
# Handle prefixed models (e.g., "image:model_name") # Handle prefixed models (e.g., "image:model_name")
elif requested_model.startswith("image:"): elif requested_model.startswith("image:"):
resolved_name = requested_model[6:] resolved_name = requested_model[6:]
...@@ -1752,6 +1775,8 @@ class MultiModelManager: ...@@ -1752,6 +1775,8 @@ class MultiModelManager:
resolved_name = requested_model[10:] resolved_name = requested_model[10:]
elif requested_model.startswith("embedding:"): elif requested_model.startswith("embedding:"):
resolved_name = requested_model[10:] resolved_name = requested_model[10:]
elif requested_model.startswith("spatial:"):
resolved_name = requested_model[8:]
else: else:
resolved_name = requested_model resolved_name = requested_model
...@@ -1809,6 +1834,7 @@ class MultiModelManager: ...@@ -1809,6 +1834,7 @@ class MultiModelManager:
"video": self.video_models, "video": self.video_models,
"audio_gen": self.audio_gen_models, "audio_gen": self.audio_gen_models,
"embedding": self.embedding_models, "embedding": self.embedding_models,
"spatial": self.spatial_models,
} }
_candidates = _type_registry.get(model_type, []) if model_type else [] _candidates = _type_registry.get(model_type, []) if model_type else []
if resolved_name not in _candidates: if resolved_name not in _candidates:
...@@ -2227,7 +2253,7 @@ class MultiModelManager: ...@@ -2227,7 +2253,7 @@ class MultiModelManager:
def list_models(self) -> List[ModelInfo]: def list_models(self) -> List[ModelInfo]:
"""List all available models (configured + runtime aliases) with type/capability metadata.""" """List all available models (configured + runtime aliases) with type/capability metadata."""
from codai.models.capabilities import detect_model_capabilities from codai.models.capabilities import detect_model_capabilities, ModelCapabilities
models = [] models = []
seen_ids: set = set() seen_ids: set = set()
...@@ -2242,6 +2268,7 @@ class MultiModelManager: ...@@ -2242,6 +2268,7 @@ class MultiModelManager:
"video_models": "video", "video_models": "video",
"audio_gen_models": "audio_gen", "audio_gen_models": "audio_gen",
"embedding_models": "embedding", "embedding_models": "embedding",
"spatial_models": "spatial",
} }
# Minimum capability guaranteed by a model's config category. # Minimum capability guaranteed by a model's config category.
...@@ -2253,22 +2280,31 @@ class MultiModelManager: ...@@ -2253,22 +2280,31 @@ class MultiModelManager:
"tts": "text_to_speech", "tts": "text_to_speech",
"audio_gen": "audio_generation", "audio_gen": "audio_generation",
"embedding": "embeddings", "embedding": "embeddings",
"spatial": "depth_estimation",
} }
def _add(model_id: str, model_type: str = None, meta: Dict[str, Any] = None): def _add(model_id: str, model_type: str = None, meta: Dict[str, Any] = None):
if model_id in seen_ids: if model_id in seen_ids:
return return
seen_ids.add(model_id) seen_ids.add(model_id)
caps = detect_model_capabilities(model_id)
# If heuristic detection missed the type (e.g. custom/vendor model IDs
# that don't match any keyword), ensure the minimum capability for the
# config-declared type is set so badges display correctly.
if model_type and model_type in TYPE_MIN_CAP:
min_cap = TYPE_MIN_CAP[model_type]
if not getattr(caps, min_cap, False):
setattr(caps, min_cap, True)
resolved_type = model_type or (caps.to_list()[0].split("_")[0] if caps.to_list() else "text")
meta = meta or {} meta = meta or {}
# Use saved capabilities from config when available; fall back to heuristic.
saved_caps = meta.get("capabilities") or []
if saved_caps:
caps = ModelCapabilities()
for f in saved_caps:
if hasattr(caps, f):
setattr(caps, f, True)
else:
caps = detect_model_capabilities(model_id)
# If heuristic detection missed the type (e.g. custom/vendor model IDs
# that don't match any keyword), ensure the minimum capability for the
# config-declared type is set so badges display correctly.
if model_type and model_type in TYPE_MIN_CAP:
min_cap = TYPE_MIN_CAP[model_type]
if not getattr(caps, min_cap, False):
setattr(caps, min_cap, True)
resolved_type = model_type or (caps.to_list()[0].split("_")[0] if caps.to_list() else "text")
models.append(ModelInfo( models.append(ModelInfo(
id=model_id, id=model_id,
type=resolved_type, type=resolved_type,
...@@ -2289,7 +2325,8 @@ class MultiModelManager: ...@@ -2289,7 +2325,8 @@ class MultiModelManager:
md = config_manager.models_data md = config_manager.models_data
for cat in ("text_models", "vision_models", "image_models", for cat in ("text_models", "vision_models", "image_models",
"audio_models", "tts_models", "gguf_models", "audio_models", "tts_models", "gguf_models",
"video_models", "audio_gen_models", "embedding_models"): "video_models", "audio_gen_models", "embedding_models",
"spatial_models"):
mtype = CAT_TYPE.get(cat, "text") mtype = CAT_TYPE.get(cat, "text")
for m in md.get(cat, []): for m in md.get(cat, []):
if isinstance(m, str): if isinstance(m, str):
...@@ -2366,6 +2403,11 @@ class MultiModelManager: ...@@ -2366,6 +2403,11 @@ class MultiModelManager:
for m in self.embedding_models: for m in self.embedding_models:
_add(f"embedding:{m}", "embedding") _add(f"embedding:{m}", "embedding")
if self.spatial_models:
_add("spatial", "spatial")
for m in self.spatial_models:
_add(f"spatial:{m}", "spatial")
# --- Custom aliases --- # --- Custom aliases ---
for alias in self.model_aliases: for alias in self.model_aliases:
_add(alias) _add(alias)
......
# CoderAI Broker Implementation Reference
## Purpose
This document is the single source of truth for implementing the CoderAI side of the AISBF broker and bridge integration.
The target audience is another LLM or engineer implementing CoderAI, not AISBF.
The implementation must let a CoderAI instance:
- expose direct HTTP and optional direct WebSocket bridge endpoints for AISBF
- connect outward to AISBF over WebSocket when CoderAI is behind NAT
- register against either a global or user-owned AISBF `coderai` provider
- receive brokered requests from AISBF
- execute those requests locally inside CoderAI
- send responses back to AISBF using the envelope protocol described here
This reference supersedes separate fragmented notes. Treat this file as the canonical contract.
## High-Level Goal
Implement a persistent outbound broker client in CoderAI plus the local handlers needed to serve AISBF requests.
The broker client should:
1. Dial AISBF over `ws://` or `wss://`
2. Authenticate using the provider-scoped `registration_token`
3. Register metadata, hardware inventory, and capabilities after connect
4. Stay connected with heartbeat support
5. Receive queued or direct broker requests from AISBF
6. Execute supported operations locally
7. Send back success, error, binary, and streaming envelopes with the same `request_id`
8. Include performance metrics for completed requests whenever available
9. Automatically reconnect if the connection drops
## AISBF Concepts You Must Match
AISBF supports `coderai` as a first-class provider.
Each `coderai` provider belongs to exactly one owner:
- global admin scope
- user scope
The broker session must register into the correct scope.
### Scope Rules
- Global provider connections use the global broker path and `username=global`
- User-owned provider connections use the user-scoped broker path and `username=<aisbf_username>`
- The registration token belongs to that exact provider configuration and owner scope
- One broker session must not be reused across unrelated owners
- AISBF rejects broker request use if the owner principal does not match the connected session owner
## Broker Endpoints
### Global Scope
```text
wss://<aisbf-host>/api/coderai/wss?provider_id=<provider_id>&client_id=<client_id>&username=global&registration_token=<token>
```
### User Scope
```text
wss://<aisbf-host>/api/u/<username>/coderai/wss?provider_id=<provider_id>&client_id=<client_id>&username=<username>&registration_token=<token>
```
### Notes
- Use `wss://` whenever AISBF is exposed through HTTPS or TLS termination
- Use the externally visible URL, not necessarily AISBF's internal bind address
- AISBF may sit behind a reverse proxy that terminates TLS
- The CoderAI client must work with both direct TLS and reverse-proxy-managed TLS
## Required Connection Parameters
The outbound WebSocket connection must include:
- `provider_id`
- `client_id`
- `username`
- `registration_token`
### Meaning
- `provider_id`: AISBF provider id such as `coderai` or `my-coderai`
- `client_id`: stable machine or session identifier chosen in provider config, such as `workstation-01`
- `username`: either `global` or the AISBF username for user-owned providers
- `registration_token`: provider-scoped secret from AISBF provider configuration
## Optional Headers
AISBF also accepts or may expect these headers:
- `Authorization: Bearer <registration_token or bridge token>`
- `x-coderai-provider-id: <provider_id>`
- `x-coderai-client-id: <client_id>`
- `x-coderai-username: <username>`
Recommended behavior:
- include both query params and headers for robustness
- use the registration token as bearer auth if no separate bridge token exists
## Broker Session Lifecycle
### 1. Connect
Open the outbound WebSocket to the correct scoped AISBF endpoint.
### 2. Wait for `registered` event
AISBF immediately sends a registration acknowledgment event on successful admission.
Example:
```json
{
"v": 1,
"event": "registered",
"session_id": "coderai_abc123",
"provider_id": "coderai",
"client_id": "workstation-01",
"username": "global",
"scope_name": "global",
"accepted": true
}
```
Store:
- `session_id`
- `provider_id`
- `client_id`
- `username`
- `scope_name`
### 3. Send explicit `register` operation
After the `registered` event, CoderAI must send a `register` message describing its capabilities, hardware inventory, and advertised endpoints.
### 4. Enter long-lived receive loop
Then keep listening for incoming broker requests from AISBF.
### 5. Heartbeat and reconnect
If the socket drops:
- reconnect with backoff
- re-register after reconnect
- preserve the same stable `client_id`
## Required `register` Message
CoderAI should send this after receiving the initial AISBF `registered` event.
```json
{
"v": 1,
"op": "register",
"request_id": "reg-1",
"payload": {
"endpoint": "ws://local-coderai-or-descriptive-endpoint",
"transport": "websocket",
"registration_token": "<same_registration_token>",
"hardware": {
"hostname": "workstation-01",
"platform": "linux",
"gpus": [
{
"index": 0,
"name": "NVIDIA RTX 4090",
"vendor": "nvidia",
"total_vram_mb": 24576,
"available_vram_mb": 20480,
"used_vram_mb": 4096
}
],
"gpu_count": 1,
"total_vram_mb": 24576,
"available_vram_mb": 20480
},
"studio_endpoints": [
"v1/images/generate",
"v1/audio/tts",
"v1/audio/transcriptions",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress"
],
"capabilities": {
"studio": {
"enabled": true,
"endpoints": [
"v1/images/generate",
"v1/images/progress",
"v1/audio/tts",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress"
],
"endpoint_capabilities": {
"v1/video/dub": {
"methods": ["POST"],
"input_modalities": ["text", "video", "audio"],
"output_modalities": ["video"],
"supports_stream": true,
"supports_multipart": true,
"supports_binary": true
},
"v1/video/progress": {
"methods": ["GET"],
"input_modalities": [],
"output_modalities": ["progress"],
"supports_stream": true,
"supports_binary": false
}
}
},
"openai_compat": {
"chat_completions": true,
"models": true,
"embeddings": true,
"images": true,
"audio": true
}
}
}
}
```
AISBF replies with a success envelope.
### Hardware Reporting Requirements
The `register` payload should include the best hardware view available to the running CoderAI process.
Required if detectable:
- `hardware.gpus`: array of GPU objects visible to the process
- `hardware.gpu_count`: integer count
- `hardware.total_vram_mb`: total usable VRAM across advertised GPUs
- `hardware.available_vram_mb`: currently free or available VRAM across advertised GPUs
Recommended per GPU fields:
- `index`
- `name`
- `vendor`
- `total_vram_mb`
- `available_vram_mb`
- `used_vram_mb`
- backend-specific extras if trivial to expose
If exact values are unavailable, estimate conservatively and include any supporting marker such as `estimated: true`.
AISBF stores this in broker session metadata so dashboards and future routing logic can reason about available hardware.
## Required Heartbeat Support
AISBF may send heartbeat requests, and CoderAI may also proactively keep the socket alive.
### If AISBF sends heartbeat
Request example:
```json
{
"v": 1,
"op": "heartbeat",
"request_id": "hb-123",
"payload": {}
}
```
Reply example:
```json
{
"v": 1,
"request_id": "hb-123",
"status": "ok",
"event": "heartbeat",
"payload": {
"ts": 1746960000
}
}
```
### Optional proactive heartbeat
CoderAI may also periodically send:
```json
{
"v": 1,
"op": "heartbeat",
"request_id": "hb-self-1",
"payload": {
"uptime": 1234
}
}
```
Heartbeat payloads may also refresh dynamic hardware state such as changing free VRAM:
```json
{
"v": 1,
"op": "heartbeat",
"request_id": "hb-self-2",
"payload": {
"hardware": {
"available_vram_mb": 18432,
"gpus": [
{
"index": 0,
"available_vram_mb": 18432,
"used_vram_mb": 6144
}
]
}
}
}
```
AISBF merges those updates into the broker session metadata.
## Local HTTP Endpoints CoderAI Should Expose
### OpenAI-compatible endpoints
At minimum:
- `GET /v1/models`
- `POST /v1/chat/completions`
Optional additional OpenAI-compatible endpoints may also be exposed if AISBF will use them.
Preferred `/v1/models` response:
```json
{
"data": [
{
"id": "llama3.1:8b",
"name": "llama3.1:8b",
"description": "Local general-purpose chat model",
"context_length": 131072,
"architecture": {
"input_modalities": ["text"],
"output_modalities": ["text"]
},
"supported_parameters": ["temperature", "top_p", "max_tokens"],
"default_parameters": {
"temperature": 0.7
},
"pricing": null,
"studio_capabilities": ["chat", "tool_use", "code_generation"]
}
]
}
```
### Capabilities endpoint
Expose:
- `GET /coderai/capabilities`
Recommended response:
```json
{
"server": {
"name": "coderai",
"version": "0.1.0"
},
"transports": {
"http": true,
"websocket": true
},
"openai_compat": {
"chat_completions": true,
"models": true,
"responses": false,
"embeddings": true,
"images": true,
"audio": true
},
"studio": {
"enabled": true,
"endpoints": [
"v1/images/generate",
"v1/images/progress",
"v1/audio/tts",
"v1/audio/transcriptions",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress"
],
"endpoint_capabilities": {
"v1/images/generate": {
"methods": ["POST"],
"input_modalities": ["text", "image"],
"output_modalities": ["image"],
"supports_stream": false,
"supports_multipart": true,
"supports_binary": true
},
"v1/images/progress": {
"methods": ["GET"],
"input_modalities": [],
"output_modalities": ["progress"],
"supports_stream": true,
"supports_binary": false
}
}
},
"models": [
{
"id": "llama3.1:8b",
"studio_capabilities": ["chat", "tool_use", "code_generation"]
}
]
}
```
## Direct WebSocket Bridge
### Path
CoderAI should accept WebSocket clients on:
- `/coderai/ws`
or another configured path mirrored in `coderai_config.bridge_path`.
### Headers AISBF sends
- `Authorization: Bearer <bridge_token_or_registration_token_or_api_key>` if available
- `x-coderai-client-id: <client_id>`
- `x-coderai-provider-id: <provider_id>`
- optionally `x-coderai-username: <username>`
## Incoming Request Envelope Format
AISBF sends one JSON envelope per operation.
Example:
```json
{
"v": 1,
"op": "chat.completions",
"request_id": "coderai-1746960000000",
"provider_id": "coderai",
"client_id": "aisbf-default",
"registration_token": "optional-shared-secret",
"payload": {
"model": "llama3.1:8b",
"messages": [
{"role": "user", "content": "hello"}
],
"stream": false
}
}
```
## Supported Operations
CoderAI must implement these operations:
- `models.list`
- `chat.completions`
- `capabilities`
- `register`
- `proxy`
- `heartbeat`
### `op = "models.list"`
Request payload:
```json
{}
```
Response payload should match `GET /v1/models`.
### `op = "chat.completions"`
Request payload matches OpenAI `POST /v1/chat/completions` body.
Completed responses should include performance metrics whenever practical:
- `latency_ms`
- `prompt_tokens`
- `completion_tokens`
- `total_tokens`
- `tokens_per_second`
If exact values are unavailable, AISBF estimates latency from broker timing and may estimate throughput from tokens plus latency.
### `op = "capabilities"`
Response payload should match `GET /coderai/capabilities`.
### `op = "register"`
Used for outbound-only broker registration and metadata refresh.
### `op = "proxy"`
Used to tunnel arbitrary Studio-native and compatible endpoints over broker or direct WebSocket transport.
Request payload may include:
```json
{
"endpoint_path": "v1/video/dub",
"method": "POST",
"headers": {
"x-request-id": "studio-job-123",
"accept": "text/event-stream"
},
"query_params": {
"job_id": "dub_123"
},
"body": {
"model": "local-video-model",
"input": "Dub this clip to Italian"
},
"multipart": {
"fields": [
{"name": "model", "value": "whisper-large"}
],
"files": [
{
"name": "file",
"filename": "sample.wav",
"content_type": "audio/wav",
"data_base64": "<base64>"
}
]
},
"content_type": "multipart/form-data",
"stream": true
}
```
Semantics:
- `headers`: forward arbitrary request headers when safe
- `query_params`: forward arbitrary query string values
- `body`: JSON body for non-multipart requests
- `multipart.fields`: repeated form fields
- `multipart.files`: uploaded files encoded in base64 with metadata
- `content_type`: original inbound content type if relevant
- `stream: true`: caller expects incremental response events instead of only a one-shot JSON body
## Response Envelope Types
### Non-streaming success
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"payload": {
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1746960000,
"model": "llama3.1:8b",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": "hello"},
"finish_reason": "stop"
}
],
"latency_ms": 842,
"tokens_per_second": 17.8,
"usage": {
"prompt_tokens": 10,
"completion_tokens": 5,
"total_tokens": 15
}
}
}
```
AISBF keeps a rolling performance window for the latest 100 completed broker requests per connected session.
When exact metrics are present, AISBF stores them directly. Otherwise it estimates:
- latency from broker request start to final reply time
- throughput from `total_tokens / latency_seconds` when token counts are present
The resulting session snapshot tracks:
- average latency
- average throughput
- average total tokens
- success rate
CoderAI should prefer sending exact `latency_ms` and `tokens_per_second` whenever it can measure them internally. AISBF estimation is only a fallback for implementations that cannot yet provide exact metrics.
### Error
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "error",
"error": "Model not available",
"code": "model_not_found",
"details": {
"model": "missing-model"
}
}
```
### Proxy success with JSON body
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"payload": {
"status_code": 200,
"headers": {
"content-type": "application/json"
},
"body": {
"job_id": "dub_123",
"status": "queued"
}
}
}
```
### Proxy success with binary body
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"payload": {
"status_code": 200,
"content_type": "audio/mpeg",
"headers": {
"content-disposition": "attachment; filename=preview.mp3"
},
"body_base64": "<base64>"
}
}
```
## Streaming Event Protocol
For long-running audio, image, video, or pipeline jobs, send multiple envelopes with the same `request_id`.
Supported event types include:
- `chunk`
- `progress`
- `output`
- `log`
- `data`
- final `done`
- final `completed`
### Chat streaming chunk example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "chunk",
"payload": {
"chunk": "data: {\"id\":\"chatcmpl-123\",\"object\":\"chat.completion.chunk\",\"choices\":[{\"delta\":{\"content\":\"hel\"},\"index\":0,\"finish_reason\":null}]}\n\n"
}
}
```
### Progress event example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "progress",
"payload": {
"chunk": "event: progress\ndata: {\"active\":true,\"current\":5,\"total\":20,\"pct\":25,\"elapsed\":12}\n\n"
}
}
```
### Binary stream chunk example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "output",
"payload": {
"chunk": {
"data_base64": "<base64>"
}
}
}
```
### Final event example
```json
{
"v": 1,
"request_id": "coderai-1746960000000",
"status": "ok",
"event": "done",
"payload": {}
}
```
Important rules:
- For SSE-style consumers, `payload.chunk` should usually already be a complete SSE fragment formatted exactly as AISBF should relay it
- Include `data: [DONE]\n\n` when upstream semantics require it
- For binary chunks, send `payload.chunk.data_base64`
- Keep all events on the same `request_id`
- End the stream with `done` or `completed`
## Progress Endpoints Used by Studio Dashboard
The AISBF Studio dashboard may proxy these progress endpoints through the broker or direct WebSocket path:
- `GET /v1/audio/progress`
- `GET /v1/video/progress`
- `GET /v1/images/progress`
You should support them when the corresponding long-running media jobs exist.
Recommended JSON shape when called over HTTP:
```json
{
"active": true,
"current": 5,
"total": 20,
"pct": 25,
"elapsed": 12,
"it_per_s": 1.4,
"unit": "steps"
}
```
Recommended streamed shape when called through broker streaming mode:
```json
{
"v": 1,
"request_id": "req-123",
"status": "ok",
"event": "progress",
"payload": {
"chunk": "event: progress\ndata: {\"active\":true,\"current\":5,\"total\":20,\"pct\":25,\"elapsed\":12,\"it_per_s\":1.4,\"unit\":\"steps\"}\n\n"
}
}
```
## Capability Advertisement Requirements
Advertise endpoint metadata clearly so AISBF can reason about custom pipelines.
For each custom endpoint, provide as many of these as possible:
- `methods`
- `input_modalities`
- `output_modalities`
- `supports_stream`
- `supports_multipart`
- `supports_binary`
- optional model restrictions
- optional job semantics such as `returns_job_id` and `requires_progress_polling`
For every model, provide:
- `id`
- `name`
- `description`
- `context_length`
- `architecture.input_modalities`
- `architecture.output_modalities`
- `supported_parameters`
- `default_parameters`
- `studio_capabilities`
For server capabilities, provide:
- transport availability
- OpenAI-compatible endpoint availability
- Studio-native endpoint availability
- endpoint capability metadata
- current server version
- optional hardware metadata such as `gpu`, `memory_gb`, `quantization`, `throughput_hint`
## Recommended CoderAI Architecture
1. OpenAI compatibility router
- exposes `/v1/models`, `/v1/chat/completions`, and any other supported OpenAI endpoints
2. Studio-native router
- exposes endpoints such as `v1/video/dub`, `v1/audio/tts`, `v1/images/generate`, progress endpoints, and other pipelines
3. Capabilities registry
- enumerates enabled endpoints and loaded models
- computes normalized `studio_capabilities`
- exposes endpoint capability metadata
4. WebSocket bridge server
- accepts AISBF envelopes
- dispatches by `op`
- handles `proxy` by internally calling the same handlers used by HTTP routes
- handles chat and non-chat streaming events
5. Optional outbound broker client
- maintains a persistent outbound WebSocket to AISBF-reachable broker endpoints
## Minimal Implementation Checklist
- add `/coderai/capabilities`
- add `/coderai/register` if you expose direct registration over HTTP
- add `/coderai/ws`
- expose model metadata with `studio_capabilities`
- support `models.list`
- support `chat.completions`
- support chat streaming `chunk` and `done`
- support `proxy` for Studio-native endpoints
- support arbitrary forwarded headers and query params in `proxy`
- support multipart uploads in `proxy`
- support base64 binary input and output in `proxy`
- support progress endpoints used by the AISBF Studio dashboard
- support non-chat streaming events for long-running media or pipeline jobs
- optionally support persistent outbound broker mode for NAT traversal
- protect bridge and register endpoints with a shared secret or signed token
## Compatibility Notes
- AISBF expects streamed chat chunks to already be formatted as SSE fragments when using chunk-style relay
- AISBF accepts binary stream chunks encoded with `data_base64`
- AISBF generic Studio proxy now uses the `coderai` bridge for non-chat endpoints, making NAT traversal possible for files, images, audio, video, and progress polling
- Owner isolation is enforced on the AISBF side, so correct scoped registration is mandatory
# AISBF Broker Client Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build an in-process AISBF broker client for CoderAI that registers over WebSocket, advertises capabilities and hardware, dispatches brokered OpenAI-compatible and studio requests through the existing FastAPI app, and returns success, error, streaming, and binary envelopes.
**Architecture:** Add a focused `codai/broker/` subsystem owned by FastAPI lifespan, with validated broker configuration, a WebSocket lifecycle client, shared capabilities/hardware reporting, an ASGI bridge for in-process execution, and a dispatcher that normalizes broker requests into existing HTTP route behavior. Drive the implementation with TDD, starting from config/protocol primitives and layering lifecycle, dispatch, streaming, and application integration on top.
**Tech Stack:** Python, FastAPI, asyncio, dataclasses, pytest, FastAPI `TestClient`, in-process ASGI invocation, WebSocket protocol adapters
---
## File Structure
### New files
- `codai/broker/__init__.py` — package exports for broker components
- `codai/broker/config.py` — broker config dataclasses, validation, scoped URL/header builders
- `codai/broker/models.py` — typed protocol models and envelope helpers
- `codai/broker/capabilities.py` — canonical capability + hardware builders
- `codai/broker/asgi_bridge.py` — in-process ASGI execution helper
- `codai/broker/streaming.py` — stream event normalization and finalization helpers
- `codai/broker/dispatcher.py` — inbound broker request validation and execution routing
- `codai/broker/client.py` — WebSocket lifecycle, handshake, heartbeat, reconnect loop
- `codai/broker/service.py` — app-facing lifecycle wrapper
- `tests/test_broker_config.py` — config validation and registration payload tests
- `tests/test_broker_protocol.py` — client protocol lifecycle and reconnect tests
- `tests/test_broker_dispatch.py` — broker request dispatch, error, and binary behavior tests
- `tests/test_broker_streaming.py` — streaming normalization and application stream bridge tests
### Modified files
- `codai/config.py` — add broker config schema and default persistence
- `codai/api/app.py` — expose `/coderai/capabilities` and manage broker service in lifespan
- `codai/main.py` — attach validated broker config/runtime dependencies to app state
- `codai/api/text.py` — only if needed to make streaming behavior easier to consume through the broker bridge without changing HTTP semantics
## Task 1: Add broker configuration schema and validation
**Files:**
- Create: `tests/test_broker_config.py`
- Create: `codai/broker/config.py`
- Modify: `codai/config.py`
- [ ] **Step 1: Write the failing config validation tests**
```python
from codai.broker.config import BrokerConfig, BrokerConfigError, build_broker_runtime_config
from codai.config import Config
def test_build_broker_runtime_config_global_scope_builds_url_and_headers():
config = Config()
config.broker = BrokerConfig(
enabled=True,
base_url="https://aisbf.example.com",
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret-token",
)
runtime = build_broker_runtime_config(config.broker)
assert runtime.websocket_url == (
"wss://aisbf.example.com/api/coderai/wss"
"?provider_id=coderai&client_id=workstation-01"
"&username=global&registration_token=secret-token"
)
assert runtime.headers["Authorization"] == "Bearer secret-token"
assert runtime.headers["x-coderai-provider-id"] == "coderai"
assert runtime.headers["x-coderai-client-id"] == "workstation-01"
assert runtime.headers["x-coderai-username"] == "global"
def test_build_broker_runtime_config_rejects_invalid_global_username():
broker = BrokerConfig(
enabled=True,
base_url="https://aisbf.example.com",
scope="global",
username="alice",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret-token",
)
try:
build_broker_runtime_config(broker)
except BrokerConfigError as exc:
assert "username=global" in str(exc)
else:
raise AssertionError("expected BrokerConfigError")
def test_build_broker_runtime_config_user_scope_uses_user_path():
broker = BrokerConfig(
enabled=True,
base_url="https://aisbf.example.com",
scope="user",
username="alice",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret-token",
)
runtime = build_broker_runtime_config(broker)
assert runtime.websocket_url == (
"wss://aisbf.example.com/api/u/alice/coderai/wss"
"?provider_id=coderai&client_id=workstation-01"
"&username=alice&registration_token=secret-token"
)
```
- [ ] **Step 2: Run the config tests to verify they fail**
Run: `pytest tests/test_broker_config.py -q`
Expected: FAIL with import errors for `codai.broker.config` or missing broker config fields.
- [ ] **Step 3: Add broker config dataclasses and URL/header builders**
```python
# codai/broker/config.py
from dataclasses import dataclass
from typing import Dict, Optional
from urllib.parse import urlencode, quote, urlparse, urlunparse
class BrokerConfigError(ValueError):
pass
@dataclass
class BrokerConfig:
enabled: bool = False
base_url: str = ""
scope: str = "global"
username: str = "global"
provider_id: str = "coderai"
client_id: str = ""
registration_token: str = ""
advertised_endpoint: Optional[str] = None
transport: str = "websocket"
heartbeat_interval_seconds: int = 30
connect_timeout_seconds: int = 15
request_timeout_seconds: int = 900
reconnect_initial_delay_seconds: int = 2
reconnect_max_delay_seconds: int = 60
@dataclass
class BrokerRuntimeConfig:
enabled: bool
websocket_url: str
headers: Dict[str, str]
scope: str
username: str
provider_id: str
client_id: str
registration_token: str
advertised_endpoint: Optional[str]
transport: str
heartbeat_interval_seconds: int
connect_timeout_seconds: int
request_timeout_seconds: int
reconnect_initial_delay_seconds: int
reconnect_max_delay_seconds: int
def build_broker_runtime_config(config: BrokerConfig) -> BrokerRuntimeConfig:
if not config.enabled:
return BrokerRuntimeConfig(
enabled=False,
websocket_url="",
headers={},
scope=config.scope,
username=config.username,
provider_id=config.provider_id,
client_id=config.client_id,
registration_token=config.registration_token,
advertised_endpoint=config.advertised_endpoint,
transport=config.transport,
heartbeat_interval_seconds=config.heartbeat_interval_seconds,
connect_timeout_seconds=config.connect_timeout_seconds,
request_timeout_seconds=config.request_timeout_seconds,
reconnect_initial_delay_seconds=config.reconnect_initial_delay_seconds,
reconnect_max_delay_seconds=config.reconnect_max_delay_seconds,
)
if config.scope == "global" and config.username != "global":
raise BrokerConfigError("global scope requires username=global")
if config.scope != "global" and not config.username:
raise BrokerConfigError("user scope requires a username")
if config.scope != "global" and config.username == "global":
raise BrokerConfigError("user scope username must not be global")
if not config.provider_id or not config.client_id or not config.registration_token:
raise BrokerConfigError("enabled broker requires provider_id, client_id, and registration_token")
parsed = urlparse(config.base_url)
scheme = "wss" if parsed.scheme == "https" else "ws" if parsed.scheme == "http" else parsed.scheme
if scheme not in {"ws", "wss"}:
raise BrokerConfigError("base_url must use http, https, ws, or wss")
if config.scope == "global":
path = "/api/coderai/wss"
else:
path = f"/api/u/{quote(config.username)}/coderai/wss"
query = urlencode(
{
"provider_id": config.provider_id,
"client_id": config.client_id,
"username": config.username,
"registration_token": config.registration_token,
}
)
websocket_url = urlunparse((scheme, parsed.netloc, path, "", query, ""))
headers = {
"Authorization": f"Bearer {config.registration_token}",
"x-coderai-provider-id": config.provider_id,
"x-coderai-client-id": config.client_id,
"x-coderai-username": config.username,
}
return BrokerRuntimeConfig(
enabled=True,
websocket_url=websocket_url,
headers=headers,
scope=config.scope,
username=config.username,
provider_id=config.provider_id,
client_id=config.client_id,
registration_token=config.registration_token,
advertised_endpoint=config.advertised_endpoint,
transport=config.transport,
heartbeat_interval_seconds=config.heartbeat_interval_seconds,
connect_timeout_seconds=config.connect_timeout_seconds,
request_timeout_seconds=config.request_timeout_seconds,
reconnect_initial_delay_seconds=config.reconnect_initial_delay_seconds,
reconnect_max_delay_seconds=config.reconnect_max_delay_seconds,
)
```
```python
# codai/config.py additions
from codai.broker.config import BrokerConfig
@dataclass
class Config:
version: str = "1.0"
server: ServerConfig = field(default_factory=ServerConfig)
backend: BackendConfig = field(default_factory=BackendConfig)
models: ModelsConfig = field(default_factory=ModelsConfig)
offload: OffloadConfig = field(default_factory=OffloadConfig)
vulkan: VulkanConfig = field(default_factory=VulkanConfig)
image: ImageConfig = field(default_factory=ImageConfig)
whisper: WhisperConfig = field(default_factory=WhisperConfig)
archive: ArchiveConfig = field(default_factory=ArchiveConfig)
broker: BrokerConfig = field(default_factory=BrokerConfig)
system_prompt: Optional[str] = None
```
- [ ] **Step 4: Load and persist the broker config in `ConfigManager`**
```python
# codai/config.py create_default_configs() snippet
"broker": {
"enabled": False,
"base_url": "",
"scope": "global",
"username": "global",
"provider_id": "coderai",
"client_id": "",
"registration_token": "",
"advertised_endpoint": None,
"transport": "websocket",
"heartbeat_interval_seconds": 30,
"connect_timeout_seconds": 15,
"request_timeout_seconds": 900,
"reconnect_initial_delay_seconds": 2,
"reconnect_max_delay_seconds": 60,
},
```
```python
# codai/config.py load() snippet
self.config = Config(
version=config_data.get("version", "1.0"),
server=ServerConfig(**config_data.get("server", {})),
backend=BackendConfig(**config_data.get("backend", {})),
models=ModelsConfig(**config_data.get("models", {})),
offload=OffloadConfig(**config_data.get("offload", {})),
vulkan=VulkanConfig(**config_data.get("vulkan", {})),
image=ImageConfig(**config_data.get("image", {})),
whisper=WhisperConfig(**config_data.get("whisper", {})),
archive=ArchiveConfig(**config_data.get("archive", {})),
broker=BrokerConfig(**config_data.get("broker", {})),
system_prompt=config_data.get("system_prompt"),
tools_closer_prompt=config_data.get("tools_closer_prompt", False),
grammar_guided=config_data.get("grammar_guided", False),
file_path=config_data.get("file_path"),
hf_chat_templates=config_data.get("hf_chat_templates", []),
reasoning_options=config_data.get("reasoning_options", []),
parser=config_data.get("parser", "auto"),
)
```
- [ ] **Step 5: Run the config tests to verify they pass**
Run: `pytest tests/test_broker_config.py -q`
Expected: PASS with 3 passing tests.
- [ ] **Step 6: Commit the config foundation**
```bash
git add tests/test_broker_config.py codai/broker/config.py codai/config.py
git commit -m "feat: add AISBF broker configuration"
```
## Task 2: Add protocol models and registration payload builder
**Files:**
- Modify: `tests/test_broker_config.py`
- Create: `codai/broker/models.py`
- Create: `codai/broker/capabilities.py`
- [ ] **Step 1: Write the failing registration payload tests**
```python
from codai.broker.capabilities import build_register_message
from codai.broker.config import BrokerRuntimeConfig
def test_build_register_message_includes_capabilities_and_hardware():
runtime = BrokerRuntimeConfig(
enabled=True,
websocket_url="wss://aisbf.example.com/api/coderai/wss?...",
headers={},
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret-token",
advertised_endpoint="http://127.0.0.1:8776",
transport="websocket",
heartbeat_interval_seconds=30,
connect_timeout_seconds=15,
request_timeout_seconds=900,
reconnect_initial_delay_seconds=2,
reconnect_max_delay_seconds=60,
)
message = build_register_message(
runtime,
request_id="reg-1",
hardware={
"hostname": "workstation-01",
"platform": "linux",
"gpus": [{"index": 0, "name": "GPU 0", "vendor": "nvidia", "total_vram_mb": 24576}],
"gpu_count": 1,
"total_vram_mb": 24576,
"available_vram_mb": 20480,
},
capabilities={
"openai_compat": {"chat_completions": True, "models": True},
"studio": {"enabled": True, "endpoints": ["v1/images/generate", "v1/video/progress"]},
},
studio_endpoints=["v1/images/generate", "v1/video/progress"],
)
assert message["op"] == "register"
assert message["request_id"] == "reg-1"
assert message["payload"]["registration_token"] == "secret-token"
assert message["payload"]["hardware"]["gpu_count"] == 1
assert message["payload"]["capabilities"]["openai_compat"]["chat_completions"] is True
assert message["payload"]["studio_endpoints"] == ["v1/images/generate", "v1/video/progress"]
def test_build_capabilities_document_lists_openai_and_studio_support():
document = {
"server": {"name": "coderai", "version": "2.0.0"},
"transports": {"http": True, "websocket": True},
"openai_compat": {"chat_completions": True, "models": True},
"studio": {"enabled": True, "endpoints": ["v1/images/generate", "v1/audio/tts"]},
}
assert document["server"]["name"] == "coderai"
assert document["transports"]["websocket"] is True
assert "v1/audio/tts" in document["studio"]["endpoints"]
```
- [ ] **Step 2: Run the expanded config tests to verify they fail**
Run: `pytest tests/test_broker_config.py -q`
Expected: FAIL because `build_register_message` and capability helpers do not exist.
- [ ] **Step 3: Add protocol model and payload helpers**
```python
# codai/broker/models.py
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
@dataclass
class BrokerRequestEnvelope:
request_id: str
method: str
path: str
headers: Dict[str, str] = field(default_factory=dict)
query: Dict[str, str] = field(default_factory=dict)
payload: Any = None
stream: bool = False
content_type: str = "application/json"
def success_envelope(request_id: str, payload: Any, *, event: Optional[str] = None, metrics: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
body = {"v": 1, "request_id": request_id, "status": "ok", "payload": payload}
if event:
body["event"] = event
if metrics is not None:
body["metrics"] = metrics
return body
def error_envelope(request_id: str, code: str, message: str, *, details: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
body = {"v": 1, "request_id": request_id, "status": "error", "error": {"code": code, "message": message}}
if details is not None:
body["error"]["details"] = details
return body
```
```python
# codai/broker/capabilities.py
from typing import Any, Dict, List, Optional
from codai.broker.config import BrokerRuntimeConfig
DEFAULT_STUDIO_ENDPOINTS = [
"v1/images/generate",
"v1/images/progress",
"v1/audio/tts",
"v1/audio/transcriptions",
"v1/audio/progress",
"v1/video/dub",
"v1/video/progress",
]
def build_capabilities_document(*, version: str = "2.0.0", studio_endpoints: Optional[List[str]] = None, hardware: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
endpoints = studio_endpoints or DEFAULT_STUDIO_ENDPOINTS
document = {
"server": {"name": "coderai", "version": version},
"transports": {"http": True, "websocket": True},
"openai_compat": {
"chat_completions": True,
"models": True,
"responses": False,
"embeddings": True,
"images": True,
"audio": True,
},
"studio": {"enabled": True, "endpoints": endpoints},
}
if hardware is not None:
document["hardware"] = hardware
return document
def build_register_message(runtime: BrokerRuntimeConfig, *, request_id: str, hardware: Dict[str, Any], capabilities: Dict[str, Any], studio_endpoints: List[str]) -> Dict[str, Any]:
return {
"v": 1,
"op": "register",
"request_id": request_id,
"payload": {
"endpoint": runtime.advertised_endpoint or "http://127.0.0.1:8776",
"transport": runtime.transport,
"registration_token": runtime.registration_token,
"hardware": hardware,
"studio_endpoints": studio_endpoints,
"capabilities": capabilities,
},
}
```
- [ ] **Step 4: Run the tests to verify the registration payload passes**
Run: `pytest tests/test_broker_config.py -q`
Expected: PASS with 5 passing tests.
- [ ] **Step 5: Commit the protocol model layer**
```bash
git add tests/test_broker_config.py codai/broker/models.py codai/broker/capabilities.py
git commit -m "feat: add broker protocol payload builders"
```
## Task 3: Add capability endpoint and app-state broker runtime wiring
**Files:**
- Modify: `tests/test_broker_config.py`
- Modify: `codai/api/app.py`
- Modify: `codai/main.py`
- Modify: `codai/broker/capabilities.py`
- Create: `codai/broker/__init__.py`
- [ ] **Step 1: Write the failing capabilities endpoint test**
```python
from fastapi.testclient import TestClient
from codai.api.app import app
def test_capabilities_endpoint_returns_shared_broker_capabilities(monkeypatch):
monkeypatch.setattr(
"codai.broker.capabilities.build_hardware_summary",
lambda: {"hostname": "workstation-01", "platform": "linux", "gpu_count": 0},
)
client = TestClient(app)
response = client.get("/coderai/capabilities")
assert response.status_code == 200
body = response.json()
assert body["server"]["name"] == "coderai"
assert body["transports"]["websocket"] is True
assert body["openai_compat"]["chat_completions"] is True
assert "v1/images/generate" in body["studio"]["endpoints"]
assert body["hardware"]["hostname"] == "workstation-01"
```
- [ ] **Step 2: Run the endpoint test to verify it fails**
Run: `pytest tests/test_broker_config.py::test_capabilities_endpoint_returns_shared_broker_capabilities -q`
Expected: FAIL with 404 for `/coderai/capabilities` or missing helper functions.
- [ ] **Step 3: Add shared hardware and capability builders plus endpoint export**
```python
# codai/broker/capabilities.py additions
import platform
import socket
from typing import Any, Dict
def build_hardware_summary() -> Dict[str, Any]:
return {
"hostname": socket.gethostname(),
"platform": platform.system().lower(),
"gpus": [],
"gpu_count": 0,
"total_vram_mb": 0,
"available_vram_mb": 0,
}
```
```python
# codai/broker/__init__.py
from codai.broker.capabilities import build_capabilities_document, build_hardware_summary, build_register_message
from codai.broker.config import BrokerConfig, BrokerRuntimeConfig, BrokerConfigError, build_broker_runtime_config
__all__ = [
"BrokerConfig",
"BrokerRuntimeConfig",
"BrokerConfigError",
"build_broker_runtime_config",
"build_capabilities_document",
"build_hardware_summary",
"build_register_message",
]
```
```python
# codai/api/app.py additions
from codai.broker.capabilities import build_capabilities_document, build_hardware_summary
@app.get("/coderai/capabilities")
async def coderai_capabilities():
hardware = build_hardware_summary()
return build_capabilities_document(hardware=hardware)
```
- [ ] **Step 4: Attach validated broker runtime config to app state in `codai/main.py`**
```python
# codai/main.py additions near app state setup
from codai.broker.config import build_broker_runtime_config
fastapi_app.state.broker_config = build_broker_runtime_config(config.broker)
```
- [ ] **Step 5: Run the endpoint test to verify it passes**
Run: `pytest tests/test_broker_config.py::test_capabilities_endpoint_returns_shared_broker_capabilities -q`
Expected: PASS.
- [ ] **Step 6: Commit the shared capability surface**
```bash
git add tests/test_broker_config.py codai/api/app.py codai/main.py codai/broker/capabilities.py codai/broker/__init__.py
git commit -m "feat: expose shared broker capabilities"
```
## Task 4: Add ASGI bridge for internal broker request execution
**Files:**
- Create: `tests/test_broker_dispatch.py`
- Create: `codai/broker/asgi_bridge.py`
- [ ] **Step 1: Write the failing ASGI bridge tests**
```python
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from codai.broker.asgi_bridge import execute_internal_request
async def test_execute_internal_request_returns_json_response():
app = FastAPI()
@app.get("/hello")
async def hello():
return {"message": "world"}
response = await execute_internal_request(app, method="GET", path="/hello")
assert response["status_code"] == 200
assert response["headers"]["content-type"].startswith("application/json")
assert response["body"] == b'{"message":"world"}'
async def test_execute_internal_request_supports_json_body():
app = FastAPI()
@app.post("/echo")
async def echo(payload: dict):
return JSONResponse({"seen": payload["value"]}, status_code=201)
response = await execute_internal_request(
app,
method="POST",
path="/echo",
headers={"content-type": "application/json"},
body=b'{"value":"x"}',
)
assert response["status_code"] == 201
assert response["body"] == b'{"seen":"x"}'
```
- [ ] **Step 2: Run the ASGI bridge tests to verify they fail**
Run: `pytest tests/test_broker_dispatch.py -q`
Expected: FAIL because `execute_internal_request` does not exist.
- [ ] **Step 3: Implement the minimal ASGI bridge**
```python
# codai/broker/asgi_bridge.py
import asyncio
from typing import Dict, Optional
from urllib.parse import urlencode
async def execute_internal_request(app, *, method: str, path: str, headers: Optional[Dict[str, str]] = None, query: Optional[Dict[str, str]] = None, body: bytes = b""):
raw_headers = [(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in (headers or {}).items()]
query_string = urlencode(query or {}).encode("ascii")
response_start = {}
response_body_parts = []
body_sent = False
scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": method.upper(),
"scheme": "http",
"path": path,
"raw_path": path.encode("ascii"),
"query_string": query_string,
"headers": raw_headers,
"client": ("127.0.0.1", 0),
"server": ("127.0.0.1", 8776),
}
async def receive():
nonlocal body_sent
if body_sent:
await asyncio.sleep(0)
return {"type": "http.disconnect"}
body_sent = True
return {"type": "http.request", "body": body, "more_body": False}
async def send(message):
if message["type"] == "http.response.start":
response_start["status_code"] = message["status"]
response_start["headers"] = {
key.decode("latin-1"): value.decode("latin-1")
for key, value in message.get("headers", [])
}
elif message["type"] == "http.response.body":
response_body_parts.append(message.get("body", b""))
await app(scope, receive, send)
return {
"status_code": response_start["status_code"],
"headers": response_start["headers"],
"body": b"".join(response_body_parts),
}
```
- [ ] **Step 4: Run the ASGI bridge tests to verify they pass**
Run: `pytest tests/test_broker_dispatch.py -q`
Expected: PASS with 2 passing tests.
- [ ] **Step 5: Commit the ASGI bridge**
```bash
git add tests/test_broker_dispatch.py codai/broker/asgi_bridge.py
git commit -m "feat: add internal ASGI bridge for broker execution"
```
## Task 5: Add broker dispatcher with request validation and JSON route execution
**Files:**
- Modify: `tests/test_broker_dispatch.py`
- Create: `codai/broker/dispatcher.py`
- Modify: `codai/broker/models.py`
- [ ] **Step 1: Write the failing dispatcher tests**
```python
import json
from fastapi import FastAPI
from codai.broker.dispatcher import execute_broker_request
from codai.broker.models import BrokerRequestEnvelope
async def test_execute_broker_request_returns_success_envelope_for_json_route():
app = FastAPI()
@app.get("/v1/models")
async def models():
return {"data": [{"id": "tiny"}]}
envelope = BrokerRequestEnvelope(request_id="req-1", method="GET", path="/v1/models")
response = await execute_broker_request(app, envelope)
assert response["request_id"] == "req-1"
assert response["status"] == "ok"
assert response["payload"]["status_code"] == 200
assert json.loads(response["payload"]["body"]) == {"data": [{"id": "tiny"}]}
async def test_execute_broker_request_rejects_unsupported_endpoint():
app = FastAPI()
envelope = BrokerRequestEnvelope(request_id="req-2", method="GET", path="/not-supported")
response = await execute_broker_request(app, envelope)
assert response["status"] == "error"
assert response["error"]["code"] == "unsupported_endpoint"
```
- [ ] **Step 2: Run the dispatcher tests to verify they fail**
Run: `pytest tests/test_broker_dispatch.py -q`
Expected: FAIL because `execute_broker_request` does not exist.
- [ ] **Step 3: Implement the minimal broker dispatcher**
```python
# codai/broker/dispatcher.py
import json
import time
from typing import Dict
from codai.broker.asgi_bridge import execute_internal_request
from codai.broker.models import BrokerRequestEnvelope, error_envelope, success_envelope
SUPPORTED_PREFIXES = (
"/v1/models",
"/v1/chat/completions",
"/v1/images",
"/v1/audio",
"/v1/video",
"/v1/pipelines",
"/coderai/capabilities",
)
def is_supported_path(path: str) -> bool:
return any(path == prefix or path.startswith(prefix + "/") for prefix in SUPPORTED_PREFIXES)
async def execute_broker_request(app, envelope: BrokerRequestEnvelope) -> Dict[str, object]:
if not envelope.request_id or not envelope.method or not envelope.path:
return error_envelope(envelope.request_id or "unknown", "malformed_request", "request_id, method, and path are required")
if not is_supported_path(envelope.path):
return error_envelope(envelope.request_id, "unsupported_endpoint", f"unsupported broker path: {envelope.path}")
started = time.perf_counter()
response = await execute_internal_request(
app,
method=envelope.method,
path=envelope.path,
headers=envelope.headers,
query=envelope.query,
body=envelope.payload if isinstance(envelope.payload, bytes) else json.dumps(envelope.payload).encode("utf-8") if envelope.payload is not None else b"",
)
metrics = {"elapsed_ms": round((time.perf_counter() - started) * 1000, 3)}
payload = {
"status_code": response["status_code"],
"headers": response["headers"],
"body": response["body"].decode("utf-8", errors="replace"),
}
return success_envelope(envelope.request_id, payload, metrics=metrics)
```
- [ ] **Step 4: Run the dispatcher tests to verify they pass**
Run: `pytest tests/test_broker_dispatch.py -q`
Expected: PASS with 4 passing tests.
- [ ] **Step 5: Commit the dispatcher baseline**
```bash
git add tests/test_broker_dispatch.py codai/broker/dispatcher.py codai/broker/models.py
git commit -m "feat: dispatch broker requests through FastAPI"
```
## Task 6: Add streaming normalization helpers and stream dispatch tests
**Files:**
- Create: `tests/test_broker_streaming.py`
- Create: `codai/broker/streaming.py`
- Modify: `codai/broker/dispatcher.py`
- [ ] **Step 1: Write the failing streaming helper tests**
```python
from codai.broker.streaming import finalize_stream, stream_chunk_envelope
def test_stream_chunk_envelope_preserves_request_id_and_order():
chunk = stream_chunk_envelope("req-1", sequence=2, data="data: hello\n\n")
assert chunk["request_id"] == "req-1"
assert chunk["event"] == "stream"
assert chunk["payload"]["sequence"] == 2
assert chunk["payload"]["data"] == "data: hello\n\n"
def test_finalize_stream_attaches_metrics():
final = finalize_stream("req-1", total_chunks=3, elapsed_ms=12.5)
assert final["request_id"] == "req-1"
assert final["event"] == "stream_end"
assert final["metrics"]["elapsed_ms"] == 12.5
assert final["payload"]["total_chunks"] == 3
```
- [ ] **Step 2: Run the streaming tests to verify they fail**
Run: `pytest tests/test_broker_streaming.py -q`
Expected: FAIL because `codai.broker.streaming` does not exist.
- [ ] **Step 3: Implement the stream envelope helpers**
```python
# codai/broker/streaming.py
from typing import Any, Dict
def stream_chunk_envelope(request_id: str, *, sequence: int, data: str) -> Dict[str, Any]:
return {
"v": 1,
"request_id": request_id,
"status": "ok",
"event": "stream",
"payload": {"sequence": sequence, "data": data},
}
def finalize_stream(request_id: str, *, total_chunks: int, elapsed_ms: float) -> Dict[str, Any]:
return {
"v": 1,
"request_id": request_id,
"status": "ok",
"event": "stream_end",
"payload": {"total_chunks": total_chunks},
"metrics": {"elapsed_ms": elapsed_ms},
}
```
- [ ] **Step 4: Add a dispatcher stream passthrough test and implementation**
```python
# tests/test_broker_streaming.py addition
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from codai.broker.dispatcher import execute_broker_request
from codai.broker.models import BrokerRequestEnvelope
async def test_execute_broker_request_wraps_streaming_response_metadata():
app = FastAPI()
@app.get("/v1/audio/progress")
async def progress():
async def generate():
yield b"data: {\"step\":1}\n\n"
yield b"data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
envelope = BrokerRequestEnvelope(request_id="req-stream", method="GET", path="/v1/audio/progress")
response = await execute_broker_request(app, envelope)
assert response["status"] == "ok"
assert response["payload"]["headers"]["content-type"].startswith("text/event-stream")
assert "data: {\"step\":1}" in response["payload"]["body"]
```
```python
# codai/broker/dispatcher.py body handling stays the same; response body should already include collected stream bytes
```
- [ ] **Step 5: Run the streaming tests to verify they pass**
Run: `pytest tests/test_broker_streaming.py -q`
Expected: PASS with 3 passing tests.
- [ ] **Step 6: Commit the streaming helpers**
```bash
git add tests/test_broker_streaming.py codai/broker/streaming.py codai/broker/dispatcher.py
git commit -m "feat: add broker streaming envelopes"
```
## Task 7: Add binary response coverage to broker dispatch
**Files:**
- Modify: `tests/test_broker_dispatch.py`
- Modify: `codai/broker/dispatcher.py`
- [ ] **Step 1: Write the failing binary response test**
```python
from fastapi import FastAPI, Response
from codai.broker.dispatcher import execute_broker_request
from codai.broker.models import BrokerRequestEnvelope
async def test_execute_broker_request_preserves_binary_payload_metadata():
app = FastAPI()
@app.get("/v1/images/generate")
async def image():
return Response(content=b"\x89PNG\r\n", media_type="image/png", headers={"x-filename": "image.png"})
envelope = BrokerRequestEnvelope(request_id="req-bin", method="GET", path="/v1/images/generate")
response = await execute_broker_request(app, envelope)
assert response["status"] == "ok"
assert response["payload"]["body_base64"] == "iVBORw0K"
assert response["payload"]["content_type"] == "image/png"
assert response["payload"]["filename"] == "image.png"
```
- [ ] **Step 2: Run the binary dispatch test to verify it fails**
Run: `pytest tests/test_broker_dispatch.py::test_execute_broker_request_preserves_binary_payload_metadata -q`
Expected: FAIL because the dispatcher currently decodes bytes as text instead of preserving binary metadata.
- [ ] **Step 3: Update the dispatcher to distinguish binary vs text bodies**
```python
# codai/broker/dispatcher.py additions
import base64
TEXT_PREFIXES = ("application/json", "text/", "application/problem+json")
def _build_response_payload(response):
content_type = response["headers"].get("content-type", "application/octet-stream")
filename = response["headers"].get("x-filename")
payload = {
"status_code": response["status_code"],
"headers": response["headers"],
"content_type": content_type,
}
if filename:
payload["filename"] = filename
if content_type.startswith(TEXT_PREFIXES):
payload["body"] = response["body"].decode("utf-8", errors="replace")
else:
payload["body_base64"] = base64.b64encode(response["body"]).decode("ascii")
return payload
```
```python
# codai/broker/dispatcher.py inside execute_broker_request
payload = _build_response_payload(response)
```
- [ ] **Step 4: Run the broker dispatch tests to verify they pass**
Run: `pytest tests/test_broker_dispatch.py -q`
Expected: PASS with binary coverage included.
- [ ] **Step 5: Commit the binary handling**
```bash
git add tests/test_broker_dispatch.py codai/broker/dispatcher.py
git commit -m "feat: preserve binary broker responses"
```
## Task 8: Add broker WebSocket client handshake and heartbeat behavior
**Files:**
- Create: `tests/test_broker_protocol.py`
- Create: `codai/broker/client.py`
- [ ] **Step 1: Write the failing broker client protocol tests**
```python
import asyncio
from codai.broker.client import BrokerClient
from codai.broker.config import BrokerRuntimeConfig
class FakeSocket:
def __init__(self, messages):
self.messages = list(messages)
self.sent = []
async def recv(self):
if self.messages:
return self.messages.pop(0)
raise RuntimeError("socket closed")
async def send(self, payload):
self.sent.append(payload)
async def close(self):
return None
async def test_broker_client_waits_for_registered_before_register(monkeypatch):
runtime = BrokerRuntimeConfig(
enabled=True,
websocket_url="wss://aisbf.example.com/api/coderai/wss?...",
headers={"Authorization": "Bearer secret"},
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret",
advertised_endpoint="http://127.0.0.1:8776",
transport="websocket",
heartbeat_interval_seconds=30,
connect_timeout_seconds=15,
request_timeout_seconds=900,
reconnect_initial_delay_seconds=2,
reconnect_max_delay_seconds=60,
)
socket = FakeSocket([
'{"v":1,"event":"registered","session_id":"sess-1","provider_id":"coderai","client_id":"workstation-01","username":"global","scope_name":"global","accepted":true}'
])
async def fake_connect(*args, **kwargs):
return socket
monkeypatch.setattr("codai.broker.client.websockets.connect", fake_connect)
monkeypatch.setattr("codai.broker.client.build_hardware_summary", lambda: {"hostname": "ws-01", "platform": "linux", "gpus": [], "gpu_count": 0, "total_vram_mb": 0, "available_vram_mb": 0})
client = BrokerClient(runtime)
await client.connect_and_register()
assert client.session_id == "sess-1"
assert any('"op": "register"' in payload for payload in socket.sent)
async def test_broker_client_replies_to_heartbeat(monkeypatch):
runtime = BrokerRuntimeConfig(
enabled=True,
websocket_url="wss://aisbf.example.com/api/coderai/wss?...",
headers={"Authorization": "Bearer secret"},
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret",
advertised_endpoint="http://127.0.0.1:8776",
transport="websocket",
heartbeat_interval_seconds=30,
connect_timeout_seconds=15,
request_timeout_seconds=900,
reconnect_initial_delay_seconds=2,
reconnect_max_delay_seconds=60,
)
socket = FakeSocket([])
client = BrokerClient(runtime)
client.websocket = socket
await client.handle_message('{"v":1,"op":"heartbeat","request_id":"hb-1","payload":{}}')
assert any('"event": "heartbeat"' in payload for payload in socket.sent)
```
- [ ] **Step 2: Run the broker protocol tests to verify they fail**
Run: `pytest tests/test_broker_protocol.py -q`
Expected: FAIL because `BrokerClient` does not exist.
- [ ] **Step 3: Implement the minimal broker client handshake and heartbeat loop**
```python
# codai/broker/client.py
import json
import time
from typing import Any, Awaitable, Callable, Dict, Optional
import websockets
from codai.broker.capabilities import DEFAULT_STUDIO_ENDPOINTS, build_capabilities_document, build_hardware_summary, build_register_message
from codai.broker.config import BrokerRuntimeConfig
from codai.broker.models import error_envelope, success_envelope
class BrokerClient:
def __init__(self, runtime: BrokerRuntimeConfig, *, dispatcher: Optional[Callable[[Dict[str, Any]], Awaitable[Dict[str, Any]]]] = None):
self.runtime = runtime
self.dispatcher = dispatcher
self.websocket = None
self.session_id = None
async def connect_and_register(self):
self.websocket = await websockets.connect(self.runtime.websocket_url, additional_headers=self.runtime.headers)
message = json.loads(await self.websocket.recv())
if message.get("event") != "registered" or not message.get("accepted"):
raise RuntimeError("expected accepted registered event")
self.session_id = message["session_id"]
hardware = build_hardware_summary()
capabilities = build_capabilities_document(hardware=hardware)
register_message = build_register_message(
self.runtime,
request_id="reg-1",
hardware=hardware,
capabilities=capabilities,
studio_endpoints=DEFAULT_STUDIO_ENDPOINTS,
)
await self.websocket.send(json.dumps(register_message))
async def handle_message(self, raw_message: str):
message = json.loads(raw_message)
if message.get("op") == "heartbeat":
response = success_envelope(
message["request_id"],
{"ts": int(time.time())},
event="heartbeat",
)
await self.websocket.send(json.dumps(response))
return response
if self.dispatcher is not None:
response = await self.dispatcher(message)
await self.websocket.send(json.dumps(response))
return response
return None
```
- [ ] **Step 4: Run the broker protocol tests to verify they pass**
Run: `pytest tests/test_broker_protocol.py -q`
Expected: PASS with 2 passing tests.
- [ ] **Step 5: Commit the broker client handshake**
```bash
git add tests/test_broker_protocol.py codai/broker/client.py
git commit -m "feat: add broker client handshake and heartbeat"
```
## Task 9: Add reconnect loop and broker service lifecycle wrapper
**Files:**
- Modify: `tests/test_broker_protocol.py`
- Create: `codai/broker/service.py`
- Modify: `codai/broker/client.py`
- Modify: `codai/api/app.py`
- [ ] **Step 1: Write the failing reconnect and service lifecycle tests**
```python
import asyncio
from codai.broker.client import BrokerClient
from codai.broker.config import BrokerRuntimeConfig
from codai.broker.service import BrokerService
async def test_broker_client_next_reconnect_delay_caps_at_max():
runtime = BrokerRuntimeConfig(
enabled=True,
websocket_url="wss://aisbf.example.com/api/coderai/wss?...",
headers={},
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret",
advertised_endpoint="http://127.0.0.1:8776",
transport="websocket",
heartbeat_interval_seconds=30,
connect_timeout_seconds=15,
request_timeout_seconds=900,
reconnect_initial_delay_seconds=2,
reconnect_max_delay_seconds=10,
)
client = BrokerClient(runtime)
assert client.next_reconnect_delay(2) == 4
assert client.next_reconnect_delay(8) == 10
assert client.next_reconnect_delay(10) == 10
async def test_broker_service_start_and_stop_manage_background_task(monkeypatch):
runtime = BrokerRuntimeConfig(
enabled=True,
websocket_url="wss://aisbf.example.com/api/coderai/wss?...",
headers={},
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret",
advertised_endpoint="http://127.0.0.1:8776",
transport="websocket",
heartbeat_interval_seconds=30,
connect_timeout_seconds=15,
request_timeout_seconds=900,
reconnect_initial_delay_seconds=2,
reconnect_max_delay_seconds=10,
)
client = BrokerClient(runtime)
async def fake_run_forever():
await asyncio.sleep(3600)
monkeypatch.setattr(client, "run_forever", fake_run_forever)
service = BrokerService(client)
await service.start()
assert service.task is not None
assert not service.task.done()
await service.stop()
assert service.task.cancelled()
```
- [ ] **Step 2: Run the reconnect tests to verify they fail**
Run: `pytest tests/test_broker_protocol.py -q`
Expected: FAIL because reconnect helpers and `BrokerService` do not exist.
- [ ] **Step 3: Implement reconnect helpers and lifecycle wrapper**
```python
# codai/broker/client.py additions
import asyncio
class BrokerClient:
...
def next_reconnect_delay(self, current_delay: int) -> int:
return min(current_delay * 2, self.runtime.reconnect_max_delay_seconds)
async def run_forever(self):
delay = self.runtime.reconnect_initial_delay_seconds
while True:
try:
await self.connect_and_register()
return
except Exception:
await asyncio.sleep(delay)
delay = self.next_reconnect_delay(delay)
```
```python
# codai/broker/service.py
import asyncio
from typing import Optional
class BrokerService:
def __init__(self, client):
self.client = client
self.task: Optional[asyncio.Task] = None
async def start(self):
if self.task is None and self.client.runtime.enabled:
self.task = asyncio.create_task(self.client.run_forever())
async def stop(self):
if self.task is not None:
self.task.cancel()
try:
await self.task
except asyncio.CancelledError:
pass
```
- [ ] **Step 4: Start the broker service from FastAPI lifespan**
```python
# codai/api/app.py lifespan additions
from codai.broker.client import BrokerClient
from codai.broker.service import BrokerService
from codai.broker.dispatcher import execute_broker_request
from codai.broker.models import BrokerRequestEnvelope
broker_service = None
@asynccontextmanager
async def lifespan(app: FastAPI):
...
runtime = getattr(app.state, "broker_config", None)
if runtime and runtime.enabled:
client = BrokerClient(runtime)
service = BrokerService(client)
app.state.broker_service = service
await service.start()
try:
yield
finally:
service = getattr(app.state, "broker_service", None)
if service is not None:
await service.stop()
...
```
- [ ] **Step 5: Run the reconnect tests to verify they pass**
Run: `pytest tests/test_broker_protocol.py -q`
Expected: PASS with 4 passing tests.
- [ ] **Step 6: Commit the service lifecycle layer**
```bash
git add tests/test_broker_protocol.py codai/broker/service.py codai/broker/client.py codai/api/app.py
git commit -m "feat: run broker client in app lifecycle"
```
## Task 10: Connect broker client dispatch to the FastAPI app
**Files:**
- Modify: `tests/test_broker_protocol.py`
- Modify: `codai/broker/client.py`
- Modify: `codai/broker/service.py`
- Modify: `codai/api/app.py`
- [ ] **Step 1: Write the failing broker dispatch integration test**
```python
import json
from fastapi import FastAPI
from codai.broker.client import BrokerClient
from codai.broker.config import BrokerRuntimeConfig
class FakeSocket:
def __init__(self):
self.sent = []
async def send(self, payload):
self.sent.append(payload)
async def test_broker_client_dispatches_request_messages_through_fastapi_app():
app = FastAPI()
@app.get("/v1/models")
async def models():
return {"data": [{"id": "tiny"}]}
runtime = BrokerRuntimeConfig(
enabled=True,
websocket_url="wss://aisbf.example.com/api/coderai/wss?...",
headers={},
scope="global",
username="global",
provider_id="coderai",
client_id="workstation-01",
registration_token="secret",
advertised_endpoint="http://127.0.0.1:8776",
transport="websocket",
heartbeat_interval_seconds=30,
connect_timeout_seconds=15,
request_timeout_seconds=900,
reconnect_initial_delay_seconds=2,
reconnect_max_delay_seconds=10,
)
async def dispatcher(message):
from codai.broker.dispatcher import execute_broker_request
from codai.broker.models import BrokerRequestEnvelope
envelope = BrokerRequestEnvelope(
request_id=message["request_id"],
method=message["method"],
path=message["path"],
headers=message.get("headers", {}),
query=message.get("query", {}),
payload=message.get("payload"),
)
return await execute_broker_request(app, envelope)
client = BrokerClient(runtime, dispatcher=dispatcher)
client.websocket = FakeSocket()
await client.handle_message('{"request_id":"req-1","method":"GET","path":"/v1/models","payload":null}')
payload = json.loads(client.websocket.sent[0])
assert payload["request_id"] == "req-1"
assert payload["status"] == "ok"
```
- [ ] **Step 2: Run the broker dispatch integration test to verify it fails**
Run: `pytest tests/test_broker_protocol.py::test_broker_client_dispatches_request_messages_through_fastapi_app -q`
Expected: FAIL because no app-aware dispatcher wiring exists.
- [ ] **Step 3: Add broker request conversion and app-aware dispatcher wiring**
```python
# codai/broker/client.py additions
from codai.broker.models import BrokerRequestEnvelope
def message_to_envelope(message: Dict[str, Any]) -> BrokerRequestEnvelope:
return BrokerRequestEnvelope(
request_id=message["request_id"],
method=message["method"],
path=message["path"],
headers=message.get("headers", {}),
query=message.get("query", {}),
payload=message.get("payload"),
stream=message.get("stream", False),
content_type=message.get("content_type", "application/json"),
)
```
```python
# codai/broker/service.py additions
from codai.broker.dispatcher import execute_broker_request
class BrokerService:
def __init__(self, client, app=None):
self.client = client
self.app = app
self.task = None
if self.app is not None:
async def dispatcher(message):
envelope = self.client.message_to_envelope(message)
return await execute_broker_request(self.app, envelope)
self.client.dispatcher = dispatcher
```
```python
# codai/api/app.py lifespan additions
client = BrokerClient(runtime)
service = BrokerService(client, app)
```
- [ ] **Step 4: Run the broker protocol tests to verify they pass**
Run: `pytest tests/test_broker_protocol.py -q`
Expected: PASS with dispatch integration included.
- [ ] **Step 5: Commit the broker-to-app dispatch wiring**
```bash
git add tests/test_broker_protocol.py codai/broker/client.py codai/broker/service.py codai/api/app.py
git commit -m "feat: route broker requests through the FastAPI app"
```
## Task 11: Verify brokered equivalence against real app endpoints
**Files:**
- Modify: `tests/test_broker_dispatch.py`
- Modify: `tests/test_broker_streaming.py`
- [ ] **Step 1: Write the failing app-equivalence tests for models and chat-style streaming**
```python
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
from codai.broker.dispatcher import execute_broker_request
from codai.broker.models import BrokerRequestEnvelope
async def test_brokered_models_match_direct_http_response_shape():
app = FastAPI()
@app.get("/v1/models")
async def models():
return {"data": [{"id": "tiny", "name": "tiny"}]}
direct = TestClient(app).get("/v1/models")
brokered = await execute_broker_request(app, BrokerRequestEnvelope(request_id="req-1", method="GET", path="/v1/models"))
assert direct.status_code == brokered["payload"]["status_code"]
assert direct.json() == json.loads(brokered["payload"]["body"])
async def test_brokered_streaming_route_preserves_event_stream_body():
app = FastAPI()
@app.post("/v1/chat/completions")
async def chat():
async def generate():
yield b"data: {\"choices\":[{\"delta\":{\"content\":\"hi\"}}]}\n\n"
yield b"data: [DONE]\n\n"
return StreamingResponse(generate(), media_type="text/event-stream")
brokered = await execute_broker_request(
app,
BrokerRequestEnvelope(request_id="req-chat", method="POST", path="/v1/chat/completions", payload={"model": "tiny", "messages": []}),
)
assert brokered["payload"]["headers"]["content-type"].startswith("text/event-stream")
assert "data: [DONE]" in brokered["payload"]["body"]
```
- [ ] **Step 2: Run the app-equivalence tests to verify they fail if shapes drift**
Run: `pytest tests/test_broker_dispatch.py tests/test_broker_streaming.py -q`
Expected: FAIL if response shaping or stream capture is incomplete.
- [ ] **Step 3: Tighten bridge/dispatcher behavior until equivalence tests pass**
```python
# Keep implementation changes minimal and localized:
# - preserve direct status codes
# - preserve direct content-type values
# - preserve event-stream body content exactly
# - avoid wrapping response bodies in extra broker-only formatting beyond the envelope payload
```
- [ ] **Step 4: Run the targeted equivalence tests to verify they pass**
Run: `pytest tests/test_broker_dispatch.py tests/test_broker_streaming.py -q`
Expected: PASS.
- [ ] **Step 5: Commit the equivalence verification changes**
```bash
git add tests/test_broker_dispatch.py tests/test_broker_streaming.py codai/broker/asgi_bridge.py codai/broker/dispatcher.py
if ! git diff --cached --quiet; then git commit -m "test: verify brokered endpoint equivalence"; fi
```
## Task 12: Run the focused broker suite and finish
**Files:**
- Test: `tests/test_broker_config.py`
- Test: `tests/test_broker_protocol.py`
- Test: `tests/test_broker_dispatch.py`
- Test: `tests/test_broker_streaming.py`
- [ ] **Step 1: Run the focused broker test suite**
Run: `pytest tests/test_broker_config.py tests/test_broker_protocol.py tests/test_broker_dispatch.py tests/test_broker_streaming.py -q`
Expected: PASS with all broker-focused tests green.
- [ ] **Step 2: Run one broader regression touchpoint for API stability**
Run: `pytest tests/test_audio_ml_endpoints.py -q`
Expected: PASS, showing existing endpoint behavior remains intact.
- [ ] **Step 3: Review the diff for accidental scope creep**
Run: `git diff --stat`
Expected: only broker-related files plus the planned integration points in `codai/config.py`, `codai/api/app.py`, `codai/main.py`, and any minimal route-safe adjustments.
- [ ] **Step 4: Commit the final integration cleanup if needed**
```bash
git add codai tests
if ! git diff --cached --quiet; then git commit -m "feat: integrate AISBF broker client support"; fi
```
# AISBF Broker Client Design
## Goal
Implement the CoderAI side of the AISBF broker protocol so a running CoderAI instance can register itself with either a global or user-owned AISBF `coderai` provider, stay connected over an outbound WebSocket, receive brokered OpenAI-compatible and studio requests, execute them locally, and return success, error, streaming, and binary envelopes using the same `request_id`.
This design targets full first-pass coverage for both:
- OpenAI-compatible brokered operations
- Studio brokered operations
## Scope
The implementation covers:
- outbound broker WebSocket connection to AISBF using scoped URLs
- registration using `provider_id`, `client_id`, `username`, and `registration_token`
- post-connect `register` payload emission with capabilities and hardware metadata
- heartbeat handling and optional proactive heartbeat support
- automatic reconnect with backoff and re-registration
- inbound request dispatch for supported OpenAI-compatible and studio endpoints
- local execution using the same application behavior as direct HTTP access
- response envelope emission for JSON, errors, streams, and binary outputs
- performance metrics on completed requests when available
- broker-aware tests for config, protocol lifecycle, dispatch, streaming, and reconnect
The implementation does not include:
- a separate sidecar worker process
- refactoring endpoint business logic out of existing FastAPI routes unless required by broker reuse
- cross-owner broker session reuse
- speculative protocol extensions not described in `coderai-broker-implementation-reference.md`
## Chosen Approach
Use an in-process broker subsystem owned by the existing CoderAI runtime.
This subsystem will live under `codai/broker/` and run as a background async service started and stopped by the existing application lifecycle. Brokered requests will be translated into in-process ASGI requests against the existing FastAPI app where practical so direct HTTP traffic and brokered AISBF traffic share the same handlers, models, validation, and response behavior.
This was chosen over a sidecar worker because the current codebase is already organized around one FastAPI process with centralized runtime state in `codai/main.py` and `codai/api/app.py`. It also avoids duplicating endpoint behavior in broker-only code.
## Architecture
### Runtime Ownership
The broker client is a long-lived runtime service.
Startup flow:
1. Load broker config from `ConfigManager`
2. Validate scope, endpoint URL inputs, and required credentials
3. Store broker config on `fastapi_app.state`
4. Start a broker service task during FastAPI lifespan startup when enabled
5. Begin connect/register/receive loop
Shutdown flow:
1. Cancel broker receive and heartbeat tasks
2. Close the WebSocket cleanly
3. Stop reconnect attempts
4. Await shutdown completion before lifespan exits
The FastAPI lifespan remains the single owner of broker lifecycle so cleanup is deterministic and consistent with existing archive/model cleanup behavior.
### Module Layout
#### `codai/broker/config.py`
Responsibilities:
- map `Config` broker fields into a validated runtime object
- enforce scope rules for `global` vs user-owned providers
- construct the correct AISBF WebSocket URL
- expose connect/reconnect/heartbeat timeouts and intervals
- redact secrets for logs
#### `codai/broker/models.py`
Responsibilities:
- define typed broker protocol models
- represent AISBF `registered` events and heartbeat requests
- represent the outgoing `register` payload
- represent inbound brokered request envelopes
- represent outbound success, error, stream, and binary envelopes
- represent optional metrics payloads
#### `codai/broker/client.py`
Responsibilities:
- open the outbound WebSocket using the scoped URL and headers
- await the initial AISBF `registered` event
- store runtime session metadata such as `session_id`
- send the required `register` operation
- run the long-lived receive loop
- answer heartbeat requests immediately
- trigger reconnect with backoff after disconnects or fatal socket failures
#### `codai/broker/capabilities.py`
Responsibilities:
- build the canonical capability document for both HTTP and broker registration
- advertise OpenAI-compatible and studio endpoints
- derive available endpoints from current application support
- collect hostname, platform, GPU count, VRAM totals, and per-GPU metadata where detectable
- fall back conservatively when exact GPU telemetry is unavailable
#### `codai/broker/dispatcher.py`
Responsibilities:
- validate inbound brokered request envelopes
- normalize inbound requests into an execution descriptor
- map request method/path/query/headers/body into internal execution calls
- reject unsupported routes with structured broker errors
- isolate each inbound request in its own async task
#### `codai/broker/asgi_bridge.py`
Responsibilities:
- construct synthetic in-process ASGI requests against the FastAPI app
- support JSON bodies, query strings, headers, multipart data, and raw bytes
- collect response status, headers, body, and streaming events
- provide a single reuse layer so brokered requests and direct HTTP traffic stay behaviorally aligned
#### `codai/broker/streaming.py`
Responsibilities:
- translate streaming application responses into broker stream envelopes
- preserve chunk ordering per `request_id`
- send a final completion envelope with aggregate metrics
- normalize progress-style studio responses and chat streaming responses
#### `codai/broker/service.py`
Responsibilities:
- expose an app-facing service wrapper with `start()` and `stop()`
- own the broker client instance and background tasks
- present a small lifecycle API to FastAPI startup/shutdown code
## Configuration Design
Add a `broker` section to `Config` and `config.json`.
Recommended fields:
```json
{
"broker": {
"enabled": false,
"base_url": "wss://aisbf.example.com",
"scope": "global",
"username": "global",
"provider_id": "coderai",
"client_id": "workstation-01",
"registration_token": "<secret>",
"advertised_endpoint": "http://127.0.0.1:8776",
"transport": "websocket",
"heartbeat_interval_seconds": 30,
"connect_timeout_seconds": 15,
"request_timeout_seconds": 900,
"reconnect_initial_delay_seconds": 2,
"reconnect_max_delay_seconds": 60
}
}
```
Validation rules:
- `enabled=false` means broker code does not start
- `scope=global` requires `username=global`
- user scope requires non-empty `username` that is not `global`
- `provider_id`, `client_id`, and `registration_token` are required when enabled
- `base_url` must resolve to `ws://` or `wss://` when converted to broker transport
- one config instance maps to exactly one owner scope and must not be reused across unrelated principals
URL construction rules:
- global scope: `/api/coderai/wss?provider_id=...&client_id=...&username=global&registration_token=...`
- user scope: `/api/u/<username>/coderai/wss?provider_id=...&client_id=...&username=<username>&registration_token=...`
Headers should also include:
- `Authorization: Bearer <registration_token>`
- `x-coderai-provider-id`
- `x-coderai-client-id`
- `x-coderai-username`
## Registration Design
After connect, the client waits for an AISBF `registered` event. Only after receiving that event does it send the explicit `register` message.
The `register` payload includes:
- advertised endpoint and transport
- registration token echo as required by reference
- hardware inventory
- studio endpoint list
- capability document
The capability document comes from one shared builder so:
- broker `register` payloads
- local `GET /coderai/capabilities` responses
are produced from the same source and cannot drift.
## Hardware Reporting Design
Hardware reporting should use the best information already available to the process.
Preferred fields:
- hostname
- platform
- `gpus`
- `gpu_count`
- `total_vram_mb`
- `available_vram_mb`
Per-GPU preferred fields:
- `index`
- `name`
- `vendor`
- `total_vram_mb`
- `available_vram_mb`
- `used_vram_mb`
If exact values are unavailable:
- omit only fields that truly cannot be determined
- estimate conservatively where the codebase already has enough information to do so safely
- include an `estimated` marker where estimation is used
The design does not require introducing heavyweight telemetry dependencies just to populate optional fields.
## Local Capability Surface
### Local HTTP endpoint
Expose:
- `GET /coderai/capabilities`
This endpoint returns:
- server identity and version
- transport support flags
- OpenAI-compatible support map
- studio enablement and endpoint list
- optional hardware summary
### Advertised broker support
First-pass broker coverage includes:
- `GET /v1/models`
- `POST /v1/chat/completions`
- representative OpenAI-compatible routes already supported by the server where AISBF may invoke them
- studio routes already exposed by the application, especially:
- `v1/images/generate`
- `v1/images/progress`
- `v1/audio/tts`
- `v1/audio/transcriptions`
- `v1/audio/progress`
- `v1/video/dub`
- `v1/video/progress`
The dispatcher should be extensible so additional routes can be added by mapping them into the same ASGI execution path without redesign.
## Request Execution Design
### Inbound request normalization
Each brokered AISBF request is normalized into an internal execution object containing at least:
- `request_id`
- HTTP method
- path
- query parameters
- headers
- body or structured payload
- content type
- streaming flag
- binary expectations if present
Malformed or incomplete requests are rejected before execution with a structured broker error envelope.
### Execution path
The preferred execution path is ASGI bridging into the existing FastAPI app.
Why:
- preserves current request validation behavior
- reuses all existing route handlers
- keeps broker logic focused on protocol translation
- avoids divergent behavior between direct and brokered clients
Broker-specific code should not reimplement chat, models, image, audio, or video endpoint logic unless an endpoint cannot be represented cleanly through the bridge.
### Concurrency
Each inbound broker request runs in its own async task so one long-running generation does not block the socket receive loop.
The broker service must continue to:
- accept heartbeat traffic
- process other inbound events
- surface task completion or failure independently per `request_id`
Timeout handling should use the configured request timeout but avoid prematurely aborting long-running studio jobs if those jobs are intentionally modeled as progress-based asynchronous flows.
## Response Envelope Design
### Success responses
Successful request completion returns an envelope containing:
- `v`
- original `request_id`
- `status: ok`
- response metadata such as HTTP status and headers when useful
- JSON payload body or binary metadata/body
- optional metrics
### Error responses
Errors return an envelope containing:
- original `request_id`
- `status: error`
- stable machine-readable error code
- human-readable message
- optional details payload
Error classes include:
- malformed broker request
- unsupported endpoint
- local validation failure
- execution failure
- timeout
- internal broker transport failure
### Streaming responses
Streaming support is required for chat completions and progress-style endpoints where the local endpoint already supports streaming or incremental progress.
Streaming design:
- emit ordered chunk envelopes using the same `request_id`
- include minimal chunk metadata needed for AISBF to reconstruct the stream
- send a final completion envelope when the stream ends
- include aggregate metrics on the final envelope when available
### Binary responses
Binary-producing endpoints such as image, audio, or video operations must return broker envelopes that preserve:
- content type
- filename or artifact naming when relevant
- byte content or the reference-compliant encoded form
- size metadata where useful
Binary encoding/normalization must be centralized in broker code, not duplicated across individual endpoints.
## Heartbeat and Reconnect Design
### Heartbeat handling
If AISBF sends:
```json
{
"v": 1,
"op": "heartbeat",
"request_id": "hb-123",
"payload": {}
}
```
CoderAI responds immediately with the same `request_id` and current timestamp.
Optional proactive heartbeat may also be sent on a configured interval. If implemented, it may include updated hardware availability such as free VRAM.
### Reconnect handling
If the WebSocket disconnects:
1. mark the session inactive
2. cancel or fail in-flight connection-scoped listeners cleanly
3. wait using bounded exponential backoff
4. reconnect using the same stable `client_id`
5. wait for a new `registered` event
6. send `register` again
7. resume the receive loop
No reconnect attempt may silently change owner scope or client identity.
## Security and Logging Design
- never log `registration_token`
- log connect target, scope, provider id, client id, and reconnect attempt counts
- log protocol failures with enough structure to debug malformed envelopes
- reject impossible scope combinations during startup rather than attempting a best-effort connection
- keep bearer token use aligned with the reference by sending it in both query params and headers for robustness
## Testing Design
This implementation should follow TDD during execution.
### Unit tests
Add focused tests for:
- broker config validation
- scoped URL construction
- header construction
- register payload generation
- capability serialization
- hardware serialization with partial data
- heartbeat request handling
- reconnect backoff progression
- inbound envelope validation
### Protocol integration tests
Use a fake AISBF WebSocket server to verify:
- successful initial connect
- waiting for `registered` before sending `register`
- correct `register` payload contents
- heartbeat request/response behavior
- reconnect and re-registration after disconnect
- error handling for malformed AISBF events
### Application integration tests
Verify equivalent behavior between direct FastAPI access and brokered execution for:
- `GET /v1/models`
- `POST /v1/chat/completions`
- at least one representative streaming endpoint
- at least one representative binary-producing studio endpoint
- at least one representative progress endpoint
### Streaming and binary tests
Add explicit tests for:
- ordered stream chunk emission
- final stream completion envelope
- metrics attachment on completion
- binary payload metadata
- multipart input translation for upload-style studio endpoints
## File Plan
Expected new files:
- `codai/broker/__init__.py`
- `codai/broker/config.py`
- `codai/broker/models.py`
- `codai/broker/client.py`
- `codai/broker/capabilities.py`
- `codai/broker/dispatcher.py`
- `codai/broker/asgi_bridge.py`
- `codai/broker/streaming.py`
- `codai/broker/service.py`
- `tests/test_broker_config.py`
- `tests/test_broker_protocol.py`
- `tests/test_broker_dispatch.py`
- `tests/test_broker_streaming.py`
Expected modified files:
- `codai/config.py`
- `codai/api/app.py`
- `codai/main.py`
- router modules only if a route needs small adjustments to be bridge-safe or capability-aware
## Risks and Mitigations
### Risk: streaming behavior differs between direct HTTP and broker execution
Mitigation:
- isolate streaming adaptation in `codai/broker/streaming.py`
- add equivalence-oriented tests against representative endpoints
### Risk: some studio routes are not easy to invoke through a synthetic ASGI request
Mitigation:
- keep ASGI bridging as the primary path
- allow a narrow fallback adapter only for endpoints whose input/output shape cannot be bridged cleanly
- keep those fallbacks inside broker modules, not scattered through endpoint code
### Risk: hardware telemetry is incomplete across backends
Mitigation:
- support partial hardware payloads
- estimate conservatively only where justified
- avoid blocking broker startup on optional telemetry
### Risk: reconnect churn causes duplicate registration confusion
Mitigation:
- treat each socket as a fresh session after `registered`
- replace stored session metadata atomically on reconnect
- reuse stable `client_id` but not stale `session_id`
## Acceptance Criteria
The design is satisfied when a configured CoderAI instance can:
- connect to either global or user-scoped AISBF broker endpoints
- receive `registered` and send a valid `register` payload
- remain connected with heartbeat support
- reconnect automatically after disconnect and re-register successfully
- serve brokered OpenAI-compatible and studio requests using current local handlers
- return structured success, error, stream, and binary responses tied to the original `request_id`
- surface capabilities and hardware metadata consistently across HTTP and broker registration
- pass the new broker-focused automated tests
# TODO: Multi-backend support for face swap, deblur, and unpixelate
We are working on **CoderAI** (`/storage/coderai`), a local AI inference server with a web studio UI. The codebase is Python/FastAPI on the backend and vanilla JS/HTML on the frontend (`codai/admin/templates/chat.html`).
We want to expand the **face swap** feature (`codai/api/faceswap.py`) to support multiple backends and models, so users can choose the best option for their hardware and use case. The same philosophy should apply to **deblur** (`/v1/images/deblur`) and **unpixelate/upscale** (`/v1/images/unpixelate`) — currently both use single hardcoded approaches (OpenCV Wiener and Real-ESRGAN respectively), but users should be able to pick between alternatives there too.
**The goal is maximum user choice** across all three features:
For **face swap**, consider offering at minimum:
- The current insightface + inswapper_128 path
- SimSwap or other ONNX-compatible swapper models
- Optional post-processing enhancers (CodeFormer, GFPGAN, or similar) that can be toggled on top of any swapper
- A facefusion-based path if it simplifies supporting multiple models via subprocess
For **deblur**, alternatives to pure OpenCV signal processing could include ML-based blind deblurring models (e.g. NAFNet, Restormer, or similar lightweight restoration networks).
For **unpixelate/upscale**, alternatives to Real-ESRGAN could include ESRGAN variants, SwinIR, HAT, or any other super-resolution model the user has downloaded.
The API should accept a `model` or `backend` parameter so the caller can select which implementation to use. Missing models should fail gracefully with a clear error rather than silently falling back. The web UI should expose the available options (discovered at runtime based on what's installed/downloaded) as a selector in the relevant panels.
Read the existing implementations before proposing changes to understand the current structure, file layout, and how the UI communicates with the backend.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment