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 diff is collapsed.
This diff is collapsed.
This diff is collapsed.
......@@ -270,7 +270,7 @@ function openDetail(entry) {
} else if (entry.type === 'audio' || entry.type === 'tts') {
const firstAudio = (entry.files || []).find(f => /\.(wav|mp3|ogg|flac)$/.test(f));
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 {
previewHtml = `<div style="font-size:3rem;padding:2rem;text-align:center">${TYPE_ICONS[entry.type]}</div>`;
}
......@@ -302,7 +302,7 @@ function openDetail(entry) {
<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>
<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>` : ''}
</div>
</div>`;
......@@ -342,7 +342,7 @@ async function deleteEntry() {
// Settings
async function loadArchiveSettings() {
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').placeholder = 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() {
retention: document.getElementById('arc-retention').value,
}
};
const r = await fetch('/admin/api/settings', {
const r = await fetch(ROOT_PATH + '/admin/api/settings', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload),
......
......@@ -7,7 +7,8 @@
<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="/static/admin/style.css">
<link rel="stylesheet" href="{{ root_path }}/static/admin/style.css">
<script>const ROOT_PATH = "{{ root_path }}";</script>
{% block head %}{% endblock %}
</head>
<body>
......@@ -16,20 +17,20 @@
<nav class="topnav">
<div class="topnav-inner">
<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>
<span class="nav-logo-name">CoderAI</span>
</a>
<div class="nav-links">
<a href="/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="/docs" class="nav-link" target="_blank">API Docs</a>
<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="/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="/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="/admin/settings" class="nav-link {% if '/settings' in request.url.path %}active{% endif %}">Settings</a>
<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>
......@@ -37,7 +38,7 @@
<div class="topnav-right">
<span class="nav-username">{{ username }}</span>
<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>
</nav>
......
......@@ -14,7 +14,7 @@
<div class="alert alert-error">{{ error }}</div>
{% endif %}
<form method="post" action="/admin/change-password">
<form method="post" action="{{ root_path }}/admin/change-password">
{% if not must_change %}
<div class="form-row">
<label class="form-label" for="old_password">Current Password</label>
......@@ -36,7 +36,7 @@
<div class="form-actions">
<button type="submit" class="btn btn-primary">Update password</button>
{% 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 %}
</div>
</form>
......
This diff is collapsed.
......@@ -47,7 +47,7 @@
<div id="active-models"><span class="muted small">No models loaded</span></div>
{% if is_admin %}
<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>
{% endif %}
</div>
......@@ -69,7 +69,7 @@
<script>
async function poll() {
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';
document.getElementById('sys-status').textContent = ok ? 'Online' : 'Error';
document.getElementById('sys-status').className = 'stat-value small ' + (ok ? 'text-green' : 'text-red');
......
......@@ -7,7 +7,8 @@
<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="/static/admin/style.css">
<link rel="stylesheet" href="{{ root_path }}/static/admin/style.css">
<script>const ROOT_PATH = "{{ root_path }}";</script>
</head>
<body>
<div class="login-wrap">
......@@ -24,7 +25,7 @@
<div class="alert alert-error" style="margin-bottom:1.25rem">{{ error }}</div>
{% endif %}
<form method="post" action="/login">
<form method="post" action="{{ root_path }}/login">
<div class="form-row">
<label class="form-label" for="username">Username</label>
<input class="form-input" type="text" id="username" name="username"
......
This diff is collapsed.
......@@ -120,7 +120,7 @@ function showAlert(type, msg){
async function loadSettings(){
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-port').value = d.server?.port ?? 8000;
document.getElementById('s-https').checked = !!d.server?.https;
......@@ -138,7 +138,7 @@ async function loadSettings(){
document.getElementById('s-arc-retention').value = arc.retention ?? 'never';
// Show effective default dir as placeholder hint
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');
if (hint && as.default_directory) hint.textContent = `(default: ${as.default_directory})`;
document.getElementById('s-arc-dir').placeholder = as.default_directory || '(default)';
......@@ -171,7 +171,7 @@ async function saveSettings(){
},
};
try{
const r = await fetch('/admin/api/settings',{
const r = await fetch(ROOT_PATH + '/admin/api/settings',{
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify(data)
});
......
......@@ -93,7 +93,7 @@ function esc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').re
async function loadTokens() {
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');
if (!tokens.length) {
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() {
const name = document.getElementById('t-name').value.trim();
if (!name) { document.getElementById('t-name').focus(); return; }
try {
const r = await fetch('/admin/api/tokens', {
const r = await fetch(ROOT_PATH + '/admin/api/tokens', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({name, provider: document.getElementById('t-provider').value})
});
......@@ -133,7 +133,7 @@ async function createToken() {
async function delToken(id) {
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');
}
......
......@@ -28,7 +28,7 @@
<td class="mono small dim">{{ user.created_at[:10] }}</td>
<td style="text-align:right">
{% 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 %}
<button class="btn btn-danger btn-sm" onclick="delUser({{ user.id }}, '{{ user.username }}')">Delete</button>
{% endif %}
......@@ -92,7 +92,7 @@ async function addUser() {
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; }
try {
const r = await fetch('/admin/api/users', {
const r = await fetch(ROOT_PATH + '/admin/api/users', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({username: uname, password: pwd, role: document.getElementById('new-role').value})
});
......@@ -103,7 +103,7 @@ async function addUser() {
async function delUser(id, name) {
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();
else { const e = await r.json(); alert(e.detail || 'Failed'); }
}
......
......@@ -137,6 +137,36 @@ app.middleware("http")(log_requests)
app.add_middleware(RateLimitMiddleware)
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
from fastapi.staticfiles import StaticFiles
from pathlib import Path
......@@ -171,7 +201,9 @@ async def unauthorized_redirect(request: Request, exc: HTTPException):
accept = request.headers.get("accept", "")
if "text/html" in accept:
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})
......@@ -204,10 +236,11 @@ _AUDIO_EXTS = {'.wav', '.mp3', '.ogg', '.flac', '.aac', '.m4a'}
@app.get("/v1/archive")
async def list_archive():
async def list_archive(request: Request):
"""List all generated files in the output directory."""
if not global_file_path or not os.path.isdir(global_file_path):
return {"files": []}
from codai.api.urlutils import build_file_url
files = []
try:
names = os.listdir(global_file_path)
......@@ -232,7 +265,7 @@ async def list_archive():
'type': ftype,
'size': stat.st_size,
'created': stat.st_mtime,
'url': f'/v1/files/{fname}',
'url': build_file_url(fname, request),
})
files.sort(key=lambda f: f['created'], reverse=True)
return {"files": files}
......
......@@ -43,21 +43,8 @@ def _ffmpeg_binary() -> str:
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:
from codai.api.urlutils import build_file_url
data = Path(path).read_bytes()
if global_file_path:
os.makedirs(global_file_path, exist_ok=True)
......@@ -65,7 +52,7 @@ def _persist_file(path: str, suffix: str, http_request: Request) -> dict:
out_path = os.path.join(global_file_path, filename)
with open(out_path, "wb") as handle:
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")}
......
......@@ -89,20 +89,8 @@ def _save_audio_response(audio_data: bytes, ext: str, http_request: Request) ->
fpath = os.path.join(global_file_path, filename)
with open(fpath, 'wb') as f:
f.write(audio_data)
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto'
if url_setting == 'auto':
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}"}
from codai.api.urlutils import build_file_url
return {"url": build_file_url(filename, http_request)}
else:
b64 = base64.b64encode(audio_data).decode()
return {f"b64_{ext}": b64}
......
......@@ -43,21 +43,8 @@ def _ffmpeg_binary() -> str:
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:
from codai.api.urlutils import build_file_url
data = Path(path).read_bytes()
if global_file_path:
os.makedirs(global_file_path, exist_ok=True)
......@@ -65,7 +52,7 @@ def _persist_file(path: str, suffix: str, http_request: Request) -> dict:
out_path = os.path.join(global_file_path, filename)
with open(out_path, "wb") as handle:
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")}
......
......@@ -251,14 +251,8 @@ async def _faceswap_video(src_img, request, http_request):
os.makedirs(global_file_path, exist_ok=True)
with open(fpath, 'wb') as f:
f.write(out_bytes)
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
data = [{'url': f'{proto}://{host}:{port}/v1/files/{fname}'}]
from codai.api.urlutils import build_file_url
data = [{'url': build_file_url(fname, http_request)}]
else:
data = [{'b64_mp4': base64.b64encode(out_bytes).decode()}]
......
......@@ -211,42 +211,8 @@ def save_image_response(img, request_format="base64", http_request=None):
file_path = os.path.join(global_file_path, filename)
img.save(file_path, format="PNG")
# Add URL to response
# Determine base URL based on --url argument
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto'
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}"
from codai.api.urlutils import build_file_url
result["url"] = build_file_url(filename, http_request)
# If client explicitly requested base64, include it
# Otherwise, only return URL when file-path is set
......@@ -1474,7 +1440,7 @@ async def create_image_upscale(request: ImageUpscaleRequest, http_request: Reque
# =============================================================================
class ImageDepthRequest(BaseModel):
model: str
model: Optional[str] = None
image: str
response_format: Optional[str] = "url"
class Config:
......@@ -1522,11 +1488,39 @@ def _run_depth(depth_model, image_bytes: bytes):
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")
async def create_image_depth(request: ImageDepthRequest, http_request: Request = None):
"""Estimate depth map from an image."""
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}"
depth_model = multi_model_manager.models.get(model_key)
if depth_model is None:
......@@ -1551,7 +1545,7 @@ async def create_image_depth(request: ImageDepthRequest, http_request: Request =
# =============================================================================
class ImageSegmentRequest(BaseModel):
model: str
model: Optional[str] = None
image: str
points: Optional[list] = None # [[x,y], ...] positive prompt points for SAM
boxes: Optional[list] = None # [[x1,y1,x2,y2], ...] box prompts
......@@ -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):
"""Segment objects in an image using SAM or similar models."""
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}"
seg_model = multi_model_manager.models.get(model_key)
if seg_model is None:
......@@ -1953,14 +1949,8 @@ async def _outfit_video(request: ImageOutfitRequest, http_request):
os.makedirs(global_file_path, exist_ok=True)
with open(fpath_out, 'wb') as f:
f.write(out_bytes)
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
data = [{'url': f'{proto}://{host}:{port}/v1/files/{fname}'}]
from codai.api.urlutils import build_file_url
data = [{'url': build_file_url(fname, http_request)}]
else:
data = [{'b64_mp4': base64.b64encode(out_bytes).decode()}]
......
......@@ -67,18 +67,8 @@ def _decode_b64(data: str) -> bytes:
def _build_url(filename: str, http_request) -> str:
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto'
if url_setting == 'auto':
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}"
from codai.api.urlutils import build_file_url
return build_file_url(filename, http_request)
def _save_output(data: bytes, ext: str, http_request) -> dict:
......@@ -111,14 +101,26 @@ def _img_to_bytes(img_pil, fmt: str = "PNG") -> bytes:
# 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:
"""Return depth array normalised to 0–1 (float32). Higher = closer."""
device = _derive_device()
# Prefer transformers depth-estimation pipeline
# Prefer transformers depth-estimation pipeline (use configured model if available)
try:
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)
depth = np.array(result['depth'], dtype=np.float32)
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):
def _build_url(filename: str, http_request) -> str:
url_setting = getattr(global_args, 'url', 'auto') if global_args else 'auto'
if url_setting == 'auto':
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}"
from codai.api.urlutils import build_file_url
return build_file_url(filename, http_request)
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:
fpath = os.path.join(global_file_path, filename)
with open(fpath, 'wb') as f:
f.write(audio_bytes)
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
return {"url": f"{proto}://{host}:{port}/v1/files/{filename}"}
from codai.api.urlutils import build_file_url
return {"url": build_file_url(filename, http_request)}
return {"b64_wav": base64.b64encode(audio_bytes).decode()}
......
......@@ -67,14 +67,8 @@ def _save_response(audio_np: np.ndarray, sr: int, http_request) -> dict:
fpath = os.path.join(global_file_path, filename)
with open(fpath, 'wb') as f:
f.write(wav_bytes)
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 {'url': f'{proto}://{host}:{port}/v1/files/{filename}'}
from codai.api.urlutils import build_file_url
return {'url': build_file_url(filename, http_request)}
return {'b64_wav': base64.b64encode(wav_bytes).decode()}
......
......@@ -463,6 +463,15 @@ def main():
if isinstance(m, dict) and m.get("alias"):
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
aliases = models_config.get("aliases", {})
for alias, model in aliases.items():
......@@ -477,7 +486,8 @@ def main():
[("tts", m) for m in tts_models] +
[("video", m) for m in video_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:
mid = _model_id(m)
......
......@@ -496,6 +496,7 @@ class MultiModelManager:
self.video_models: List[str] = [] # video generation models (t2v / i2v / v2v)
self.audio_gen_models: List[str] = [] # music / sfx generation (MusicGen, AudioLDM2…)
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.tool_parser = ModelParserAdapter()
self.current_model_key: Optional[str] = None
......@@ -868,6 +869,13 @@ class MultiModelManager:
self.config[f"embedding:{model_name}"] = config or {}
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):
"""Register an alias for a model."""
self.model_aliases[alias] = model_name
......@@ -934,6 +942,13 @@ class MultiModelManager:
allowed.add(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
for alias in self.model_aliases:
allowed.add(alias)
......@@ -945,7 +960,8 @@ class MultiModelManager:
md = config_manager.models_data
for cat in ("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"):
for m in md.get(cat, []):
mid = (m if isinstance(m, str) else
m.get("alias") or m.get("path") or m.get("id") or "")
......@@ -995,6 +1011,9 @@ class MultiModelManager:
for m in self.embedding_models:
if _matches(m):
return "embedding"
for m in self.spatial_models:
if _matches(m):
return "spatial"
return None
def is_allowed_model(self, requested_or_resolved: str, model_type: str = None) -> bool:
......@@ -1462,7 +1481,7 @@ class MultiModelManager:
bare = model_key.split(":", 1)[1] if ":" in model_key else model_key
for cat in ("text_models", "image_models", "audio_models", "tts_models",
"vision_models", "video_models", "audio_gen_models",
"embedding_models"):
"embedding_models", "spatial_models"):
for entry in config_manager.models_data.get(cat, []):
if not isinstance(entry, dict):
continue
......@@ -1712,6 +1731,8 @@ class MultiModelManager:
resolved_name = self.audio_gen_models[0] if self.audio_gen_models else None
elif model_type == "embedding":
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:
resolved_name = self.default_model
else:
......@@ -1737,6 +1758,8 @@ class MultiModelManager:
resolved_name = self.audio_gen_models[0] if self.audio_gen_models else None
elif requested_model == "embedding":
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")
elif requested_model.startswith("image:"):
resolved_name = requested_model[6:]
......@@ -1752,6 +1775,8 @@ class MultiModelManager:
resolved_name = requested_model[10:]
elif requested_model.startswith("embedding:"):
resolved_name = requested_model[10:]
elif requested_model.startswith("spatial:"):
resolved_name = requested_model[8:]
else:
resolved_name = requested_model
......@@ -1809,6 +1834,7 @@ class MultiModelManager:
"video": self.video_models,
"audio_gen": self.audio_gen_models,
"embedding": self.embedding_models,
"spatial": self.spatial_models,
}
_candidates = _type_registry.get(model_type, []) if model_type else []
if resolved_name not in _candidates:
......@@ -2227,7 +2253,7 @@ class MultiModelManager:
def list_models(self) -> List[ModelInfo]:
"""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 = []
seen_ids: set = set()
......@@ -2242,6 +2268,7 @@ class MultiModelManager:
"video_models": "video",
"audio_gen_models": "audio_gen",
"embedding_models": "embedding",
"spatial_models": "spatial",
}
# Minimum capability guaranteed by a model's config category.
......@@ -2253,22 +2280,31 @@ class MultiModelManager:
"tts": "text_to_speech",
"audio_gen": "audio_generation",
"embedding": "embeddings",
"spatial": "depth_estimation",
}
def _add(model_id: str, model_type: str = None, meta: Dict[str, Any] = None):
if model_id in seen_ids:
return
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 {}
# 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(
id=model_id,
type=resolved_type,
......@@ -2289,7 +2325,8 @@ class MultiModelManager:
md = config_manager.models_data
for cat in ("text_models", "vision_models", "image_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")
for m in md.get(cat, []):
if isinstance(m, str):
......@@ -2366,6 +2403,11 @@ class MultiModelManager:
for m in self.embedding_models:
_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 ---
for alias in self.model_aliases:
_add(alias)
......
This diff is collapsed.
This diff is collapsed.
# 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