admin: every remote/RunPod setting is now a form field

None of this session's work had any UI, so all of it was JSON-only. Now:

MODEL PAGE — a Placement section: serve this model from a remote URL
(with its name and token on that remote), and burst-to-RunPod with the
three triggers (no free slot / queue full / no local GPU) and a target
that is either a managed pod or your own serverless endpoint. The pod
form gained engine (auto/vLLM/llama.cpp/coderai/custom), the GGUF to
download, image, health path, docker args, the private-registry
credential, the pod's bearer token with an explicit "leave it open"
opt-out, and the scaling knobs (scale-up threshold, max in-flight per
pod, sticky sessions).

SETTINGS PAGE — a remote service URL for each of the five engines
(ds4, colibri, k3, ktransformers, vLLM), the account-wide registry
credential, and a Remote capabilities card: one row per capability,
each Local / Remote URL / RunPod pod, with image, shared pool name,
VRAM, $/hr, max pods and idle timeout.

Clearing a remote field now REMOVES it. The save path only writes keys
that are present, so sending an empty value as "unchanged" would have
left a stale remote in place and the model would have kept going to a
machine the user thought they had detached.

Tests assert what is easy to get silently wrong in UI work: every
getElementById reads an id that exists (counting ids the page assigns to
elements it creates), every new field is both populated and collected,
every capability the gateway can route has a row, and the admin API
keeps every key the form sends.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0182DhR1eFNPQyGmDrpHbedY
parent 9ad9e19f
......@@ -2710,13 +2710,24 @@ async def api_model_configure(request: Request, username: str = Depends(require_
"auto_compact_model", "suppress_reasoning",
# Speech-to-text (wav2vec2 / vosk / NeMo-Canary) options.
"languages", "supports_translation", "keep_resident",
# Serve this model from somewhere else instead of loading it here:
# any OpenAI-compatible endpoint (another coderai, llama-server,
# vLLM, a rented pod). See codai/backends/remote_openai.py.
"service_url", "served_model", "api_key"):
):
if key in data:
entry[key] = data[key]
# Serve this model from somewhere else instead of loading it here: any
# OpenAI-compatible endpoint (another coderai, llama-server, vLLM, a rented
# pod). See codai/backends/remote_openai.py. Blank REMOVES the setting — a
# key that is merely absent would leave a stale remote in place, and the
# model would keep going to a machine the user thought they had detached.
for key in ("service_url", "served_model", "api_key"):
if key in data:
val = (data.get(key) or "").strip() if isinstance(data.get(key), str) \
else data.get(key)
if val:
entry[key] = val
else:
entry.pop(key, None)
# Per-model ds4 launch overrides (only meaningful for a deepseek4 model served
# via the ds4 engine). Normalize to a small dict and drop it when empty, so the
# entry stays clean and inherits the global ds4 config as the default.
......@@ -2773,7 +2784,10 @@ async def api_model_configure(request: Request, username: str = Depends(require_
rpo = {}
# strings
for k in ("mode", "selection_criteria", "gpu_type", "image", "served_model",
"endpoint_id", "cost_period"):
"endpoint_id", "cost_period",
# engine selection, private registries, and the pod's own token
"engine", "hf_gguf", "health_path", "docker_args",
"registry_auth_id", "api_key"):
v = src.get(k)
if isinstance(v, str) and v.strip():
rpo[k] = v.strip()
......@@ -2788,14 +2802,15 @@ async def api_model_configure(request: Request, username: str = Depends(require_
if ct:
rpo["cloud_types"] = ct
# bools
for k in ("allow_spot",):
for k in ("allow_spot", "allow_open_pod", "sticky_sessions"):
if k in src and src.get(k) is not None and src.get(k) != "":
sv = src.get(k)
rpo[k] = (sv.lower() in ("1", "true", "on", "yes")
if isinstance(sv, str) else bool(sv))
# ints
for k in ("container_disk_gb", "volume_gb", "port", "ctx", "min_pods",
"max_pods", "scale_up_inflight_per_pod", "idle_timeout_s",
"max_pods", "scale_up_inflight_per_pod", "max_inflight_per_pod",
"idle_timeout_s",
"boot_timeout_s", "load_timeout_s", "min_workers", "max_workers"):
v = src.get(k)
if v not in (None, ""):
......
......@@ -755,8 +755,26 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<span class="form-hint" style="font-size:11px">Wait for vLLM to serve the model — raise this for big models (long image pull + weight download).</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 2fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Pod engine</label>
<select id="cfg-rp-engine" class="form-input" onchange="_updateRunpodEngineHint()">
<option value="auto">Auto — vLLM, or llama.cpp for a GGUF</option>
<option value="vllm">vLLM (HF repo)</option>
<option value="llamacpp">llama.cpp (GGUF)</option>
<option value="coderai">coderai (a capability image)</option>
<option value="custom">Custom image</option>
</select>
<span class="form-hint" id="cfg-rp-engine-hint" style="font-size:11px"></span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">GGUF to serve <span class="muted">(llama.cpp pods)</span></label>
<input type="text" id="cfg-rp-hf-gguf" class="form-input" placeholder="user/repo:Q4_K_M">
<span class="form-hint" style="font-size:11px">A local .gguf path means nothing on a rented machine — name the HF repo the pod should download.</span>
</div>
</div>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0"><label class="form-label">Pod image <span class="muted">(blank = default vLLM)</span></label><input type="text" id="cfg-rp-image" class="form-input" placeholder="vllm/vllm-openai:latest"></div>
<div class="form-row" style="margin:0"><label class="form-label">Pod image <span class="muted">(blank = engine default)</span></label><input type="text" id="cfg-rp-image" class="form-input" placeholder="vllm/vllm-openai:latest"></div>
<div class="form-row" style="margin:0"><label class="form-label">Disk (GB)</label><input type="number" id="cfg-rp-disk" class="form-input" min="1" placeholder="40"></div>
<div class="form-row" style="margin:0"><label class="form-label">Port</label><input type="number" id="cfg-rp-port" class="form-input" min="1" placeholder="8000"></div>
</div>
......@@ -764,6 +782,52 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
<label class="form-label">Pod env <span class="muted">(KEY=VAL, space-separated — e.g. HF_TOKEN=…)</span></label>
<input type="text" id="cfg-rp-env" class="form-input" placeholder="HF_TOKEN=hf_xxx">
</div>
<!-- Access. A RunPod proxy URL is reachable by anyone who learns it. -->
<div style="display:grid;grid-template-columns:2fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Pod bearer token <span class="muted">(blank = generated)</span></label>
<input type="text" id="cfg-rp-api-key" class="form-input" placeholder="auto-generated per pool">
<span class="form-hint" style="font-size:11px">The pod is launched requiring this token and coderai sends it. Blank does NOT mean open — one is generated.</span>
</div>
<div class="form-row" style="margin:0;align-self:end">
<label style="display:flex;align-items:center;gap:.3rem;font-size:12px"><input type="checkbox" id="cfg-rp-open" style="width:auto"> Leave pod open
</label>
<span class="form-hint" style="font-size:11px;color:var(--warn,#b58900)">Anyone with the pod URL can use your GPU. Only for images with no auth support.</span>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Registry credential id <span class="muted">(private images)</span></label>
<input type="text" id="cfg-rp-registry-auth" class="form-input" placeholder="blank = account default / public image">
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Health path <span class="muted">(custom images)</span></label>
<input type="text" id="cfg-rp-health-path" class="form-input" placeholder="/v1/models — /healthz for a coderai pod">
</div>
</div>
<div class="form-row" style="margin:.5rem 0 0">
<label class="form-label">Docker args <span class="muted">(custom images — replaces the generated ones)</span></label>
<input type="text" id="cfg-rp-docker-args" class="form-input" placeholder="blank = generated for the engine above">
</div>
<!-- Concurrency: how the pool tracks real load. -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Scale up at <span class="muted">(in-flight/pod)</span></label>
<input type="number" id="cfg-rp-scale-up" class="form-input" min="1" placeholder="4">
<span class="form-hint" style="font-size:11px">Add a pod once the least-loaded one carries this many requests.</span>
</div>
<div class="form-row" style="margin:0">
<label class="form-label">Max in-flight per pod</label>
<input type="number" id="cfg-rp-max-inflight" class="form-input" min="0" placeholder="0 = unlimited">
<span class="form-hint" style="font-size:11px">Above this a request waits for a slot instead of piling on.</span>
</div>
<div class="form-row" style="margin:0;align-self:end">
<label style="display:flex;align-items:center;gap:.3rem;font-size:12px"><input type="checkbox" id="cfg-rp-sticky" style="width:auto" checked> Sticky sessions</label>
<span class="form-hint" style="font-size:11px">Send a conversation back to the pod whose cache still holds it.</span>
</div>
</div>
</div>
<!-- Cost budget (cumulative spend cap, distinct from the $/hr rate cap) -->
......@@ -782,6 +846,76 @@ window.__DEFAULT_WHISPER_SERVER_PATH__ = {{ default_whisper_server_path|tojson }
</div>
</div>
<!-- ============ Where this model runs ============ -->
<div class="form-section" style="margin-top:.75rem">
<div class="form-section-title">Placement — where this model runs</div>
<!-- (1) Served from another machine entirely. -->
<div class="form-row" style="margin:0">
<label class="form-label">Serve from a remote URL <span class="muted">(any OpenAI-compatible endpoint)</span></label>
<input type="text" id="cfg-remote-url" class="form-input" placeholder="blank = run it here — e.g. http://gpu-box:8080">
<span class="form-hint" style="font-size:11px">Another coderai, llama-server, vLLM, a pod. Nothing is downloaded and no local VRAM is used.</span>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0"><label class="form-label">Name on the remote <span class="muted">(optional)</span></label><input type="text" id="cfg-remote-served" class="form-input" placeholder="blank = this model's own name"></div>
<div class="form-row" style="margin:0"><label class="form-label">Remote bearer token <span class="muted">(optional)</span></label><input type="text" id="cfg-remote-key" class="form-input" placeholder="if the remote requires one"></div>
</div>
<!-- (2) Local primary, burst the overflow to RunPod. -->
<div class="form-row" style="margin:.75rem 0 0">
<label style="display:flex;align-items:center;gap:.4rem;cursor:pointer">
<input type="checkbox" id="cfg-spill-enabled" style="width:auto" onchange="_updateSpillVisibility()">
<span class="form-label" style="margin:0">Burst to RunPod when local can't keep up</span>
</label>
<span class="form-hint" style="font-size:11px">Local stays primary. If a burst can't be opened the request just queues locally.</span>
</div>
<div id="cfg-spill-box" style="display:none;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Burst when…</label>
<div style="display:flex;gap:1rem;flex-wrap:wrap;font-size:12px">
<label style="display:flex;align-items:center;gap:.3rem"><input type="checkbox" id="cfg-spill-busy" style="width:auto"> No free local slot <span class="muted">(the 2nd concurrent request)</span></label>
<label style="display:flex;align-items:center;gap:.3rem"><input type="checkbox" id="cfg-spill-queue" style="width:auto"> The wait queue is full</label>
<label style="display:flex;align-items:center;gap:.3rem"><input type="checkbox" id="cfg-spill-nogpu" style="width:auto"> No local GPU can serve it</label>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Burst target</label>
<select id="cfg-spill-mode" class="form-input" onchange="_updateSpillVisibility()">
<option value="pods">Pods — coderai provisions, scales and reaps them</option>
<option value="serverless">Serverless — your own RunPod endpoint</option>
</select>
</div>
<div class="form-row" style="margin:0"><label class="form-label">Name on the target <span class="muted">(optional)</span></label><input type="text" id="cfg-spill-served" class="form-input" placeholder="blank = this model's own name"></div>
</div>
<div id="cfg-spill-serverless" style="margin-top:.5rem;display:none">
<div class="form-row" style="margin:0"><label class="form-label">Serverless endpoint id</label><input type="text" id="cfg-spill-endpoint" class="form-input" placeholder="from your RunPod serverless endpoint"></div>
</div>
<div id="cfg-spill-pods" style="margin-top:.5rem">
<div style="display:grid;grid-template-columns:1fr 2fr;gap:.5rem">
<div class="form-row" style="margin:0">
<label class="form-label">Engine</label>
<select id="cfg-spill-engine" class="form-input">
<option value="auto">Auto</option>
<option value="vllm">vLLM (HF repo)</option>
<option value="llamacpp">llama.cpp (GGUF)</option>
<option value="coderai">coderai image</option>
<option value="custom">Custom image</option>
</select>
</div>
<div class="form-row" style="margin:0"><label class="form-label">GGUF to serve / image</label><input type="text" id="cfg-spill-hf-gguf" class="form-input" placeholder="user/repo:Q4_K_M"></div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:.5rem;margin-top:.5rem">
<div class="form-row" style="margin:0"><label class="form-label">Min VRAM (GB)</label><input type="number" id="cfg-spill-min-vram" class="form-input" min="0" placeholder="0 = any"></div>
<div class="form-row" style="margin:0"><label class="form-label">Max $/hr</label><input type="number" id="cfg-spill-max-hourly" class="form-input" min="0" step="0.01" placeholder="0 = no cap"></div>
<div class="form-row" style="margin:0"><label class="form-label">Max pods</label><input type="number" id="cfg-spill-max-pods" class="form-input" min="1" placeholder="1"></div>
<div class="form-row" style="margin:0"><label class="form-label">Idle timeout (s)</label><input type="number" id="cfg-spill-idle" class="form-input" min="0" placeholder="300"></div>
</div>
<div class="form-row" style="margin:.5rem 0 0"><label class="form-label">Pod image <span class="muted">(coderai / custom engines)</span></label><input type="text" id="cfg-spill-image" class="form-input" placeholder="ghcr.io/you/coderai-images:0.2.0"></div>
</div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:.75rem;margin-top:.75rem">
<div class="form-row" style="margin:0">
<label class="form-label">Used VRAM <span class="muted">(GB)</span></label>
......@@ -4012,7 +4146,100 @@ function _populateRunpod(s){
set('cfg-rp-env', Object.keys(env).map(k=>k+'='+env[k]).join(' '));
set('cfg-rp-cost-limit', rp.cost_limit_usd != null ? rp.cost_limit_usd : '');
set('cfg-rp-cost-period', rp.cost_period || 'unlimited');
set('cfg-rp-engine', rp.engine || 'auto');
set('cfg-rp-hf-gguf', rp.hf_gguf || '');
set('cfg-rp-api-key', rp.api_key || '');
chk('cfg-rp-open', rp.allow_open_pod);
set('cfg-rp-registry-auth', rp.registry_auth_id || '');
set('cfg-rp-health-path', rp.health_path || '');
set('cfg-rp-docker-args', rp.docker_args || '');
set('cfg-rp-scale-up', rp.scale_up_inflight_per_pod != null ? rp.scale_up_inflight_per_pod : '');
set('cfg-rp-max-inflight', rp.max_inflight_per_pod != null ? rp.max_inflight_per_pod : '');
chk('cfg-rp-sticky', rp.sticky_sessions !== false);
_updateRunpodVisibility();
_updateRunpodEngineHint();
_populatePlacement(s);
}
// Placement: a remote URL for this model, and/or burst-to-RunPod spillover.
function _populatePlacement(s){
const set = (id,v)=>{ const el=document.getElementById(id); if(el!=null) el.value=(v==null?'':v); };
const chk = (id,v)=>{ const el=document.getElementById(id); if(el) el.checked=!!v; };
set('cfg-remote-url', (s && s.service_url) || '');
set('cfg-remote-served', (s && s.served_model) || '');
set('cfg-remote-key', (s && s.api_key) || '');
const sp = (s && s.runpod_spillover) || {};
const tg = sp.target || {};
chk('cfg-spill-enabled', sp.enabled);
chk('cfg-spill-busy', sp.on_busy);
chk('cfg-spill-queue', sp.on_concurrency_full);
chk('cfg-spill-nogpu', sp.on_no_gpu);
set('cfg-spill-mode', tg.mode || 'pods');
set('cfg-spill-served', tg.served_model || '');
set('cfg-spill-endpoint', tg.endpoint_id || '');
set('cfg-spill-engine', tg.engine || 'auto');
set('cfg-spill-hf-gguf', tg.hf_gguf || '');
set('cfg-spill-min-vram', tg.min_vram_gb != null ? tg.min_vram_gb : '');
set('cfg-spill-max-hourly', tg.max_hourly_usd != null ? tg.max_hourly_usd : '');
set('cfg-spill-max-pods', tg.max_pods != null ? tg.max_pods : '');
set('cfg-spill-idle', tg.idle_timeout_s != null ? tg.idle_timeout_s : '');
set('cfg-spill-image', tg.image || '');
_updateSpillVisibility();
}
function _updateSpillVisibility(){
const on = (document.getElementById('cfg-spill-enabled') || {}).checked;
const box = document.getElementById('cfg-spill-box');
if (box) box.style.display = on ? '' : 'none';
const mode = (document.getElementById('cfg-spill-mode') || {}).value || 'pods';
const sl = document.getElementById('cfg-spill-serverless');
const pd = document.getElementById('cfg-spill-pods');
if (sl) sl.style.display = (mode === 'serverless') ? '' : 'none';
if (pd) pd.style.display = (mode === 'serverless') ? 'none' : '';
}
function _updateRunpodEngineHint(){
const el = document.getElementById('cfg-rp-engine');
const hint = document.getElementById('cfg-rp-engine-hint');
if (!el || !hint) return;
const HINTS = {
auto: 'A GGUF goes to llama.cpp, anything else to vLLM.',
vllm: 'Serves an HF repo. vLLM\'s GGUF support is experimental — use llama.cpp for those.',
llamacpp: 'Serves the GGUF named on the right; the pod downloads it itself.',
coderai: 'A whole coderai serving one capability. Needs a pod image you have pushed.',
custom: 'Your image, your docker args, your health path.',
};
hint.textContent = HINTS[el.value] || '';
}
// Build the per-model spillover block. Undefined when disabled, so the entry
// stays clean and the model is simply local.
function _collectSpillover(){
const on = (document.getElementById('cfg-spill-enabled') || {}).checked;
if (!on) return undefined;
const val = id => (document.getElementById(id) || {}).value;
const numOrU = id => { const v = parseFloat(val(id)); return isNaN(v) ? undefined : v; };
const intOrU = id => { const v = parseInt(val(id)); return isNaN(v) ? undefined : v; };
const mode = val('cfg-spill-mode') || 'pods';
const target = { mode: mode, served_model: (val('cfg-spill-served') || '').trim() };
if (mode === 'serverless') {
target.endpoint_id = (val('cfg-spill-endpoint') || '').trim();
} else {
target.engine = val('cfg-spill-engine') || 'auto';
target.hf_gguf = (val('cfg-spill-hf-gguf') || '').trim();
target.min_vram_gb = numOrU('cfg-spill-min-vram');
target.max_hourly_usd= numOrU('cfg-spill-max-hourly');
target.max_pods = intOrU('cfg-spill-max-pods');
target.idle_timeout_s= intOrU('cfg-spill-idle');
target.image = (val('cfg-spill-image') || '').trim();
}
return {
enabled: true,
on_busy: (document.getElementById('cfg-spill-busy') || {}).checked,
on_concurrency_full: (document.getElementById('cfg-spill-queue') || {}).checked,
on_no_gpu: (document.getElementById('cfg-spill-nogpu') || {}).checked,
target: target,
};
}
// Build the per-model runpod block. Only meaningful when backend=runpod; the
// backend normalizes/drops empty fields.
......@@ -4052,6 +4279,16 @@ function _collectRunpod(){
env: Object.keys(env).length ? env : undefined,
cost_limit_usd: numOrU('cfg-rp-cost-limit'),
cost_period: val('cfg-rp-cost-period') || 'unlimited',
engine: val('cfg-rp-engine') || 'auto',
hf_gguf: (val('cfg-rp-hf-gguf') || '').trim(),
api_key: (val('cfg-rp-api-key') || '').trim(),
allow_open_pod: document.getElementById('cfg-rp-open').checked,
registry_auth_id: (val('cfg-rp-registry-auth') || '').trim(),
health_path: (val('cfg-rp-health-path') || '').trim(),
docker_args: (val('cfg-rp-docker-args') || '').trim(),
scale_up_inflight_per_pod: intOrU('cfg-rp-scale-up'),
max_inflight_per_pod: intOrU('cfg-rp-max-inflight'),
sticky_sessions: document.getElementById('cfg-rp-sticky').checked,
};
return o;
}
......@@ -4075,6 +4312,13 @@ async function saveModelConfig(){
config_name: document.getElementById('cfg-config-name').value.trim() || null,
backend: document.getElementById('cfg-backend').value,
runpod: _collectRunpod(),
// Placement: serve this model from elsewhere, and/or burst the overflow.
// Always sent, even when blank: an empty value is how you REMOVE a remote,
// and a key that is simply absent leaves the old one in place.
service_url: (document.getElementById('cfg-remote-url').value || '').trim(),
served_model: (document.getElementById('cfg-remote-served').value || '').trim(),
api_key: (document.getElementById('cfg-remote-key').value || '').trim(),
runpod_spillover: _collectSpillover(),
load_mode: document.getElementById('cfg-load-mode').value,
languages: (function(){ const v=document.getElementById('cfg-stt-languages').value.trim();
return v ? v.split(',').map(s=>s.trim()).filter(Boolean) : []; })(),
......
......@@ -478,6 +478,11 @@
<input type="text" id="s-ds4-install-dir" class="form-input" placeholder="~/.coderai/ds4">
</div>
</div>
<div class="form-group">
<label class="form-label">Remote service URL <span class="muted">(optional)</span></label>
<input type="text" id="s-ds4-service-url" class="form-input" placeholder="blank = run it here">
<span class="form-hint">Point at a ds4-server running on another host or a rented pod. Nothing is built, downloaded or launched locally — coderai proxies to it and checks its health first.</span>
</div>
<div class="form-row">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="s-ds4-auto-build">
......@@ -618,6 +623,11 @@
<input type="text" id="s-colibri-install-dir" class="form-input" placeholder="~/.coderai/colibri">
</div>
</div>
<div class="form-group">
<label class="form-label">Remote service URL <span class="muted">(optional)</span></label>
<input type="text" id="s-colibri-service-url" class="form-input" placeholder="blank = run it here">
<span class="form-hint">Point at a colibri engine hosted by tools/mux_service.py elsewhere. Nothing is built, downloaded or launched locally — coderai proxies to it and checks its health first.</span>
</div>
<div class="form-group">
<label class="toggle-row" style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="s-colibri-auto-build">
......@@ -658,6 +668,11 @@
<input type="text" id="s-k3-model-path" class="form-input" placeholder="/nvme/Kimi-K3">
<span class="form-hint">The Kimi-K3 checkpoint <b>directory</b> (config.json + tokenizer + safetensors shards).</span>
</div>
<div class="form-group">
<label class="form-label">Remote service URL <span class="muted">(optional)</span></label>
<input type="text" id="s-k3-service-url" class="form-input" placeholder="blank = run it here">
<span class="form-hint">Point at a k3 engine hosted by tools/mux_service.py elsewhere. Nothing is built, downloaded or launched locally — coderai proxies to it and checks its health first.</span>
</div>
<div class="form-group">
<label class="form-label">Packed trunk dir</label>
<input type="text" id="s-k3-trunk-dir" class="form-input" placeholder="/nvme/Kimi-K3-trunk">
......@@ -722,6 +737,11 @@
<label class="form-label">Model path (HF dir)</label>
<input type="text" id="s-kt-model-path" class="form-input" placeholder="/nvme/DeepSeek-V4-Flash">
</div>
<div class="form-group">
<label class="form-label">Remote service URL <span class="muted">(optional)</span></label>
<input type="text" id="s-kt-service-url" class="form-input" placeholder="blank = run it here">
<span class="form-hint">Point at an SGLang server running elsewhere. Nothing is built, downloaded or launched locally — coderai proxies to it and checks its health first.</span>
</div>
<div class="form-group">
<label class="form-label">KT weight path</label>
<input type="text" id="s-kt-weight-path" class="form-input" placeholder="/nvme/DeepSeek-V4-Flash-kt">
......@@ -798,6 +818,11 @@
<label class="form-label">Isolated venv dir</label>
<input type="text" id="s-vllm-venv" class="form-input" placeholder="(auto: /opt/coderai/vllm_venv, else /cache/vllm_venv)">
</div>
<div class="form-group">
<label class="form-label">Remote service URL <span class="muted">(optional)</span></label>
<input type="text" id="s-vllm-service-url" class="form-input" placeholder="blank = run it here">
<span class="form-hint">Point at a vLLM OpenAI server elsewhere — RunPod publishes a ready-made vLLM image. Nothing is built, downloaded or launched locally — coderai proxies to it and checks its health first.</span>
</div>
<div class="form-group">
<label class="form-label">Extra args</label>
<input type="text" id="s-vllm-extra-args" class="form-input" placeholder="--enable-prefix-caching --swap-space 4">
......@@ -871,6 +896,11 @@
<label class="form-label">Data center <span class="muted">(optional filter)</span></label>
<input type="text" id="s-runpod-dc" class="form-input" placeholder="(any)">
</div>
<div class="form-group" style="flex:1;min-width:180px">
<label class="form-label">Registry credential id <span class="muted">(private images)</span></label>
<input type="text" id="s-runpod-registry-auth" class="form-input" placeholder="blank = public images only">
<span class="form-hint" style="font-size:11px">From RunPod → Settings → Container Registry Credentials. Needed for a private pod image; public ones (vLLM, llama.cpp) need none.</span>
</div>
<div class="form-group" style="flex:1;min-width:150px">
<label class="form-label">Deployment id</label>
<input type="text" id="s-runpod-deploy" class="form-input" placeholder="default">
......@@ -896,6 +926,34 @@
</div>
</div>
<!-- Remote capabilities: serve whole subsystems from another coderai / a pod -->
<div class="card">
<div class="card-title">Remote capabilities</div>
<p class="form-hint" style="margin-bottom:.6rem">Images, video, embeddings, TTS, STT and the rest load their model <b>in this process</b>, so there is no per-model way to move them. Point a capability somewhere else and matching <code>/v1</code> requests are forwarded there instead — to another coderai, or to a RunPod pod coderai provisions, scales and reaps for you. Per-model <code>service_url</code> still wins over anything set here. Chat and completions are not listed: those are per-model, on the model's own page.</p>
<div class="form-row">
<label style="display:flex;align-items:center;gap:.5rem;cursor:pointer">
<input type="checkbox" id="s-remotes-enabled" onchange="toggleRemotesFields()">
<span style="font-size:13px;font-weight:500">Enable remote capabilities</span>
</label>
</div>
<div id="remotes-fields" style="display:none">
<div class="form-row" style="display:flex;gap:1rem;flex-wrap:wrap">
<div class="form-group" style="flex:1;min-width:200px">
<label class="form-label">Bearer token for remotes <span class="muted">(optional)</span></label>
<input type="text" id="s-remotes-key" class="form-input" placeholder="sent to plain URL targets">
<span class="form-hint" style="font-size:11px">A managed pod uses its own generated token instead; this is for URLs you run yourself.</span>
</div>
<div class="form-group" style="flex:1;min-width:160px">
<label class="form-label">Max forwarded body (MB)</label>
<input type="number" id="s-remotes-max-body" class="form-input" min="1" placeholder="512">
<span class="form-hint" style="font-size:11px">Larger uploads are served locally rather than buffered in RAM.</span>
</div>
</div>
<div id="s-remotes-rows" style="margin-top:.4rem"></div>
<p class="form-hint" style="margin-top:.5rem">A pod row needs an image you have pushed to a registry RunPod can pull from — see <code>packaging/runpod/publish_capability_images.sh</code>. Give several capabilities the same <b>pool</b> name and they share one pod and one budget.</p>
</div>
</div>
<!-- OCR (dedicated document OCR engines) -->
<div class="card">
<div class="card-title">OCR (document → text)</div>
......@@ -1702,6 +1760,7 @@ async function loadSettings(){
document.getElementById('s-ds4-ssd-streaming').checked = !!ds4.ssd_streaming;
document.getElementById('s-ds4-build-target').value = ds4.build_target ?? 'auto';
document.getElementById('s-ds4-install-dir').value = ds4.install_dir ?? '';
document.getElementById('s-ds4-service-url').value = ds4.service_url ?? '';
document.getElementById('s-ds4-auto-build').checked = ds4.auto_build !== false;
document.getElementById('s-ds4-host').value = ds4.host ?? '127.0.0.1';
document.getElementById('s-ds4-port').value = ds4.port ?? 0;
......@@ -1727,6 +1786,7 @@ async function loadSettings(){
document.getElementById('s-colibri-cuda-expert-gb').value = colibri.cuda_expert_gb ?? '';
document.getElementById('s-colibri-build-target').value = colibri.build_target ?? 'auto';
document.getElementById('s-colibri-install-dir').value = colibri.install_dir ?? '';
document.getElementById('s-colibri-service-url').value = colibri.service_url ?? '';
document.getElementById('s-colibri-auto-build').checked = colibri.auto_build !== false;
document.getElementById('s-colibri-repo-url').value = colibri.repo_url ?? 'https://github.com/JustVugg/colibri';
document.getElementById('s-colibri-extra-env').value = colibri.extra_env ?? '';
......@@ -1737,6 +1797,7 @@ async function loadSettings(){
document.getElementById('s-k3-enabled').checked = !!k3.enabled;
document.getElementById('s-k3-model-id').value = k3.model_id ?? 'kimi-k3';
document.getElementById('s-k3-model-path').value = k3.model_path ?? '';
document.getElementById('s-k3-service-url').value = k3.service_url ?? '';
document.getElementById('s-k3-trunk-dir').value = k3.trunk_dir ?? '';
document.getElementById('s-k3-preset').value = k3.preset ?? '';
document.getElementById('s-k3-trunk-gb').value = k3.trunk_gb ?? 16;
......@@ -1750,6 +1811,7 @@ async function loadSettings(){
document.getElementById('s-kt-enabled').checked = !!kt.enabled;
document.getElementById('s-kt-model-id').value = kt.model_id ?? 'ktransformers';
document.getElementById('s-kt-model-path').value = kt.model_path ?? '';
document.getElementById('s-kt-service-url').value = kt.service_url ?? '';
document.getElementById('s-kt-weight-path').value = kt.kt_weight_path ?? '';
document.getElementById('s-kt-host').value = kt.host ?? '127.0.0.1';
document.getElementById('s-kt-port').value = kt.port ?? 0;
......@@ -1773,6 +1835,7 @@ async function loadSettings(){
document.getElementById('s-vllm-quant').value = vll.quantization ?? '';
document.getElementById('s-vllm-port').value = vll.port ?? 0;
document.getElementById('s-vllm-venv').value = vll.venv ?? '';
document.getElementById('s-vllm-service-url').value = vll.service_url ?? '';
document.getElementById('s-vllm-extra-args').value = vll.extra_args ?? '';
document.getElementById('s-vllm-extra-env').value = vll.extra_env ?? '';
document.getElementById('s-vllm-auto-build').checked = !!vll.auto_build;
......@@ -1788,6 +1851,9 @@ async function loadSettings(){
document.getElementById('s-runpod-cloud').value = rp.cloud_type || 'SECURE';
document.getElementById('s-runpod-default-gpu').value = rp.default_gpu_type ?? '';
document.getElementById('s-runpod-budget').value = rp.global_max_hourly_usd ?? 0;
document.getElementById('s-runpod-registry-auth').value = rp.registry_auth_id ?? '';
// Remote capabilities
_populateRemotes(d.remotes || {});
document.getElementById('s-runpod-cost-limit').value = rp.global_cost_limit_usd ?? 0;
document.getElementById('s-runpod-cost-period').value = rp.global_cost_period || 'unlimited';
document.getElementById('s-runpod-dc').value = rp.data_center ?? '';
......@@ -1908,6 +1974,7 @@ async function saveSettings(silent){
ssd_streaming: document.getElementById('s-ds4-ssd-streaming').checked,
build_target: document.getElementById('s-ds4-build-target').value,
install_dir: document.getElementById('s-ds4-install-dir').value.trim(),
service_url: document.getElementById('s-ds4-service-url').value.trim(),
auto_build: document.getElementById('s-ds4-auto-build').checked,
host: document.getElementById('s-ds4-host').value.trim() || '127.0.0.1',
port: parseInt(document.getElementById('s-ds4-port').value) || 0,
......@@ -1930,6 +1997,7 @@ async function saveSettings(silent){
cuda_expert_gb: document.getElementById('s-colibri-cuda-expert-gb').value.trim(),
build_target: document.getElementById('s-colibri-build-target').value,
install_dir: document.getElementById('s-colibri-install-dir').value.trim(),
service_url: document.getElementById('s-colibri-service-url').value.trim(),
auto_build: document.getElementById('s-colibri-auto-build').checked,
repo_url: document.getElementById('s-colibri-repo-url').value.trim() || 'https://github.com/JustVugg/colibri',
extra_env: document.getElementById('s-colibri-extra-env').value.trim(),
......@@ -1938,6 +2006,7 @@ async function saveSettings(silent){
enabled: document.getElementById('s-k3-enabled').checked,
model_id: document.getElementById('s-k3-model-id').value.trim() || 'kimi-k3',
model_path: document.getElementById('s-k3-model-path').value.trim(),
service_url: document.getElementById('s-k3-service-url').value.trim(),
trunk_dir: document.getElementById('s-k3-trunk-dir').value.trim(),
preset: document.getElementById('s-k3-preset').value,
trunk_gb: parseFloat(document.getElementById('s-k3-trunk-gb').value) || 16,
......@@ -1950,6 +2019,7 @@ async function saveSettings(silent){
enabled: document.getElementById('s-kt-enabled').checked,
model_id: document.getElementById('s-kt-model-id').value.trim() || 'ktransformers',
model_path: document.getElementById('s-kt-model-path').value.trim(),
service_url: document.getElementById('s-kt-service-url').value.trim(),
kt_weight_path: document.getElementById('s-kt-weight-path').value.trim(),
host: document.getElementById('s-kt-host').value.trim() || '127.0.0.1',
port: parseInt(document.getElementById('s-kt-port').value) || 0,
......@@ -1971,10 +2041,12 @@ async function saveSettings(silent){
quantization: document.getElementById('s-vllm-quant').value.trim(),
port: parseInt(document.getElementById('s-vllm-port').value) || 0,
venv: document.getElementById('s-vllm-venv').value.trim(),
service_url: document.getElementById('s-vllm-service-url').value.trim(),
extra_args: document.getElementById('s-vllm-extra-args').value.trim(),
extra_env: document.getElementById('s-vllm-extra-env').value.trim(),
auto_build: document.getElementById('s-vllm-auto-build').checked,
},
remotes: _collectRemotes(),
runpod:{
enabled: document.getElementById('s-runpod-enabled').checked,
// Blank = keep the current key (backend ignores empty / masked values).
......@@ -1982,6 +2054,7 @@ async function saveSettings(silent){
cloud_type: document.getElementById('s-runpod-cloud').value || 'SECURE',
default_gpu_type: document.getElementById('s-runpod-default-gpu').value.trim(),
global_max_hourly_usd: parseFloat(document.getElementById('s-runpod-budget').value) || 0,
registry_auth_id: document.getElementById('s-runpod-registry-auth').value.trim(),
global_cost_limit_usd: parseFloat(document.getElementById('s-runpod-cost-limit').value) || 0,
global_cost_period: document.getElementById('s-runpod-cost-period').value || 'unlimited',
data_center: document.getElementById('s-runpod-dc').value.trim(),
......@@ -2126,5 +2199,136 @@ function _initSettingsTabs(){
show(active);
}
_initSettingsTabs();
// ---------------------------------------------------------------------------
// Remote capabilities: one row per capability, each Local / URL / RunPod pod.
// ---------------------------------------------------------------------------
const REMOTE_CAPS = [
['images','Images','/v1/images/*'],
['video','Video','/v1/video/*'],
['embeddings','Embeddings','/v1/embeddings'],
['rerank','Rerank','/v1/rerank'],
['ocr','OCR','/v1/ocr'],
['tts','Text-to-speech','/v1/audio/speech'],
['stt','Transcription','/v1/audio/transcriptions'],
['voice','Voice cloning','/v1/audio/clone, /v1/audio/voices'],
['speaker','Diarization / speakers','/v1/audio/diarization, speaker-*'],
['audio_gen','Audio generation','/v1/audio/generate'],
['stems','Stem separation','/v1/audio/stems'],
['audio_clean','Audio cleanup','/v1/audio/cleanup'],
['spatial','3D','/v1/3d/*, to3d, from3d'],
['faceswap','Face swap','/v1/images/faceswap'],
['loras','LoRA training','/v1/loras/*'],
['characters','Characters','/v1/characters/*'],
['environments','Environments','/v1/environments/*'],
['pipelines','Pipelines','/v1/pipelines/*'],
];
function toggleRemotesFields(){
const on = document.getElementById('s-remotes-enabled').checked;
document.getElementById('remotes-fields').style.display = on ? 'block' : 'none';
}
function _remoteRowHtml(cap, label, paths){
return `
<div class="form-row" style="display:flex;gap:.5rem;align-items:flex-start;flex-wrap:wrap;border-top:1px solid var(--border,#2a2a2a);padding-top:.45rem;margin-top:.45rem">
<div style="flex:0 0 190px">
<div style="font-size:13px;font-weight:500">${label}</div>
<div class="muted" style="font-size:11px"><code>${paths}</code></div>
</div>
<select id="rc-${cap}-where" class="form-input" style="flex:0 0 150px" onchange="_updateRemoteRow('${cap}')">
<option value="local">Local</option>
<option value="url">Remote URL</option>
<option value="pod">RunPod pod</option>
</select>
<input type="text" id="rc-${cap}-url" class="form-input" style="flex:1;min-width:200px;display:none" placeholder="http://gpu-box:8000">
<div id="rc-${cap}-pod" style="display:none;flex:1;min-width:320px">
<div style="display:flex;gap:.4rem;flex-wrap:wrap">
<input type="text" id="rc-${cap}-image" class="form-input" style="flex:2;min-width:200px" placeholder="ghcr.io/you/coderai-${cap}:latest">
<input type="text" id="rc-${cap}-pool" class="form-input" style="flex:1;min-width:90px" placeholder="pool (share)">
</div>
<div style="display:flex;gap:.4rem;flex-wrap:wrap;margin-top:.35rem">
<input type="number" id="rc-${cap}-vram" class="form-input" style="flex:1;min-width:80px" min="0" placeholder="min VRAM">
<input type="number" id="rc-${cap}-hourly" class="form-input" style="flex:1;min-width:80px" min="0" step="0.01" placeholder="max $/hr">
<input type="number" id="rc-${cap}-maxpods" class="form-input" style="flex:1;min-width:70px" min="1" placeholder="max pods">
<input type="number" id="rc-${cap}-idle" class="form-input" style="flex:1;min-width:80px" min="0" placeholder="idle s">
</div>
</div>
</div>`;
}
function _updateRemoteRow(cap){
const where = (document.getElementById('rc-' + cap + '-where') || {}).value || 'local';
const url = document.getElementById('rc-' + cap + '-url');
const pod = document.getElementById('rc-' + cap + '-pod');
if (url) url.style.display = (where === 'url') ? '' : 'none';
if (pod) pod.style.display = (where === 'pod') ? '' : 'none';
}
function _populateRemotes(rem){
document.getElementById('s-remotes-enabled').checked = rem.enabled !== false && (
Object.keys(rem.endpoints || {}).length > 0 || Object.keys(rem.pods || {}).length > 0);
document.getElementById('s-remotes-key').value = rem.api_key ?? '';
document.getElementById('s-remotes-max-body').value = rem.max_body_mb ?? '';
const host = document.getElementById('s-remotes-rows');
host.innerHTML = REMOTE_CAPS.map(c => _remoteRowHtml(c[0], c[1], c[2])).join('');
const eps = rem.endpoints || {}, pods = rem.pods || {};
REMOTE_CAPS.forEach(([cap]) => {
const set = (sfx, v) => { const el = document.getElementById('rc-' + cap + '-' + sfx);
if (el) el.value = (v == null ? '' : v); };
const ep = eps[cap], pd = pods[cap];
// "runpod" as the endpoint is the shorthand for "use the pod block".
if (pd || (ep && String(ep).toLowerCase() === 'runpod')) {
set('where', 'pod');
const b = pd || {};
set('image', b.image); set('pool', b.pool); set('vram', b.min_vram_gb);
set('hourly', b.max_hourly_usd); set('maxpods', b.max_pods); set('idle', b.idle_timeout_s);
} else if (ep) {
set('where', 'url'); set('url', ep);
} else {
set('where', 'local');
}
_updateRemoteRow(cap);
});
toggleRemotesFields();
}
function _collectRemotes(){
const endpoints = {}, pods = {};
const val = id => (document.getElementById(id) || {}).value;
const numOrU = id => { const v = parseFloat(val(id)); return isNaN(v) ? undefined : v; };
const intOrU = id => { const v = parseInt(val(id)); return isNaN(v) ? undefined : v; };
REMOTE_CAPS.forEach(([cap]) => {
const where = val('rc-' + cap + '-where') || 'local';
if (where === 'url') {
const u = (val('rc-' + cap + '-url') || '').trim();
if (u) endpoints[cap] = u;
} else if (where === 'pod') {
const b = {};
const img = (val('rc-' + cap + '-image') || '').trim();
const pool = (val('rc-' + cap + '-pool') || '').trim();
if (img) b.image = img;
if (pool) b.pool = pool;
const vram = numOrU('rc-' + cap + '-vram');
const hourly = numOrU('rc-' + cap + '-hourly');
const maxpods = intOrU('rc-' + cap + '-maxpods');
const idle = intOrU('rc-' + cap + '-idle');
if (vram != null) b.min_vram_gb = vram;
if (hourly != null) b.max_hourly_usd = hourly;
if (maxpods != null) b.max_pods = maxpods;
if (idle != null) b.idle_timeout_s = idle;
pods[cap] = b;
}
// 'local' -> absent from both maps, which is what "run it here" means.
});
return {
enabled: document.getElementById('s-remotes-enabled').checked,
api_key: (val('s-remotes-key') || '').trim(),
max_body_mb: intOrU('s-remotes-max-body') ?? 512,
endpoints: endpoints,
pods: pods,
};
}
</script>
{% endblock %}
# Web Admin Polish and Studio Recovery 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:** Restore dark-theme consistency and fix the broken admin/studio usability regressions for recent activity, split-pane sizing, capability warnings, and pipelines.
**Architecture:** Keep the existing admin stack intact by making focused changes in the shared admin stylesheet, the overview status/data path, and the current studio template. Fix data issues at the backend source where possible, and keep studio UI additions inside small helper functions within the existing template rather than introducing a new rendering layer.
**Tech Stack:** FastAPI, Jinja templates, vanilla JavaScript, shared CSS, Python request logging
---
## File Structure
- Modify: `codai/admin/static/style.css`
- Shared dark-theme form control rules and scrollbar styling for admin surfaces.
- Modify: `codai/api/log.py`
- Recent-activity tracking coverage and stable log entry shape.
- Modify: `codai/admin/routes.py`
- Normalize `/admin/api/status` recent-activity payload.
- Modify: `codai/admin/templates/dashboard.html`
- Keep overview rendering aligned with normalized activity rows and valid badge classes.
- Modify: `codai/admin/templates/chat.html`
- Wider generation control panel, output capability warnings, and rebuilt minimal pipeline browser/editor.
- Modify: `tests/test_studio_composed_surfaces.py`
- Extend existing studio/admin surface assertions if applicable.
- Create or modify: backend/admin tests near existing route/log coverage once exact locations are confirmed during implementation.
## Task 1: Dark theme controls and scrollbar styling
**Files:**
- Modify: `codai/admin/static/style.css:1-253`
- Test: existing admin UI/manual verification surfaces
- [ ] **Step 1: Write the failing test or assertion target**
If there is an existing HTML/CSS surface test file for admin templates, add assertions that rendered studio/admin controls include the dark-theme classes/select markup you will rely on. If no CSS/template assertion test exists, record this as a manual-verification-only task and do not invent brittle CSS snapshot coverage.
Expected target checks:
```text
- select.form-input uses dark shared styling path
- studio .fselect/.fi/.fs remain dark-themed
- scrollbar styling declarations exist in shared stylesheet
```
- [ ] **Step 2: Run the relevant test target or confirm no automated CSS test exists**
Run one of the existing focused test commands if a matching test file already exists, for example:
```bash
pytest tests/test_studio_composed_surfaces.py -q
```
Expected: either PASS on unrelated existing assertions or confirmation that no direct CSS coverage exists yet.
- [ ] **Step 3: Implement the shared stylesheet updates**
Update `codai/admin/static/style.css` to:
```css
html {
scroll-behavior: smooth;
scrollbar-color: var(--border-2) var(--nav);
}
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;
}
button,
input,
select,
textarea {
font-family: inherit;
color-scheme: dark;
}
.form-input,
select.form-input,
textarea.form-input {
background: var(--raised);
color: var(--text);
}
select.form-input option,
select.form-input optgroup {
background: var(--raised);
color: var(--text);
}
```
Then mirror the same color treatment in the studio-local classes inside `chat.html` only if the shared stylesheet does not reach `.fi`, `.fs`, and `.fselect`.
- [ ] **Step 4: Run the focused verification**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py -q
```
Expected: PASS if the file exists and remains green.
- [ ] **Step 5: Commit**
```bash
git add codai/admin/static/style.css codai/admin/templates/chat.html tests/test_studio_composed_surfaces.py
git commit -m "fix: align admin controls with dark theme"
```
## Task 2: Restore recent activity data in overview
**Files:**
- Modify: `codai/api/log.py:21-90`
- Modify: `codai/admin/routes.py:252-394`
- Modify: `codai/admin/templates/dashboard.html:55-142`
- Test: route/log tests in existing backend test suite
- [ ] **Step 1: Write the failing backend test**
Add or update a focused test that exercises the status payload after tracked activity is recorded. The test should create one or more log entries through the request logging path or by seeding the in-memory activity buffer, then assert `/admin/api/status` returns normalized rows.
Target shape:
```python
def test_admin_status_includes_recent_activity(client, admin_auth_headers):
from codai.api import log as api_log
api_log._activity.clear()
api_log._activity.appendleft({
"time": 1715000000,
"model": "demo-model",
"type": "chat",
"status": 200,
"duration": 1.23,
})
response = client.get("/admin/api/status", headers=admin_auth_headers)
assert response.status_code == 200
payload = response.json()
assert payload["recent_activity"][0]["model"] == "demo-model"
assert payload["recent_activity"][0]["type"] == "chat"
assert payload["recent_activity"][0]["status"] == 200
```
Use the project’s existing auth/client fixtures and exact test file location once discovered.
- [ ] **Step 2: Run the focused backend test to verify it fails**
Run the exact test node you added, for example:
```bash
pytest tests/<path-to-admin-or-routes-test>.py::test_admin_status_includes_recent_activity -q
```
Expected: FAIL before normalization/tracking fixes land.
- [ ] **Step 3: Expand request tracking and normalize the status payload**
In `codai/api/log.py`, extend `_TRACKED_PATHS` to cover all studio-visible endpoints that should appear in activity, keeping the same stable row shape:
```python
_TRACKED_PATHS = {
"/v1/chat/completions": "chat",
"/v1/completions": "completion",
"/v1/images/generations": "image",
"/v1/audio/speech": "tts",
"/v1/audio/transcriptions": "transcription",
"/v1/embeddings": "embedding",
# add any currently used studio endpoints for video/audio/image edits if present
}
```
In `codai/admin/routes.py`, normalize rows before returning them:
```python
normalized_activity = []
for row in recent_activity:
if not isinstance(row, dict):
continue
normalized_activity.append({
"time": int(row.get("time", 0) or 0),
"model": str(row.get("model") or "—"),
"type": str(row.get("type") or "unknown"),
"status": int(row.get("status", 0) or 0),
"duration": round(float(row.get("duration", 0) or 0), 2),
})
```
Return `normalized_activity` instead of the raw list.
If `dashboard.html` currently uses a nonexistent CSS class such as `badge-danger`, replace it with a valid class already defined in `style.css` or add a shared danger badge style in the stylesheet.
- [ ] **Step 4: Run the focused backend test to verify it passes**
Run:
```bash
pytest tests/<path-to-admin-or-routes-test>.py::test_admin_status_includes_recent_activity -q
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add codai/api/log.py codai/admin/routes.py codai/admin/templates/dashboard.html codai/admin/static/style.css tests/<path-to-admin-or-routes-test>.py
git commit -m "fix: restore admin recent activity feed"
```
## Task 3: Widen studio generation control panes
**Files:**
- Modify: `codai/admin/templates/chat.html:109-126`
- Test: `tests/test_studio_composed_surfaces.py`
- [ ] **Step 1: Write the failing template assertion**
Extend `tests/test_studio_composed_surfaces.py` with an assertion that the studio template includes the updated wider `.gen-ctrl` rule.
Example assertion content:
```python
def test_studio_generation_panel_uses_wider_control_column():
html = render_chat_template_somehow(...)
assert ".gen-ctrl { width:min(380px, 36vw);" in html
```
Use the existing test style in the file rather than inventing a new render helper.
- [ ] **Step 2: Run the focused studio test to verify it fails**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py::test_studio_generation_panel_uses_wider_control_column -q
```
Expected: FAIL before the CSS update.
- [ ] **Step 3: Implement the responsive width change**
In `codai/admin/templates/chat.html`, replace the current narrow fixed rule:
```css
.gen-ctrl {
width: 290px;
...
}
```
with a responsive rule such as:
```css
.gen-ctrl {
width: min(380px, 36vw);
min-width: 340px;
max-width: 420px;
padding: .9rem 1rem;
overflow-y: auto;
border-right: 1px solid var(--border);
background: var(--surface-1);
display: flex;
flex-direction: column;
gap: .65rem;
flex-shrink: 0;
}
```
Add a mobile media query if needed so small screens can drop below the desktop min-width without horizontal breakage.
- [ ] **Step 4: Run the focused studio test to verify it passes**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py::test_studio_generation_panel_uses_wider_control_column -q
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add codai/admin/templates/chat.html tests/test_studio_composed_surfaces.py
git commit -m "fix: widen studio control panels"
```
## Task 4: Mirror partial and unavailable capability notes in output panes
**Files:**
- Modify: `codai/admin/templates/chat.html`
- Test: `tests/test_studio_composed_surfaces.py`
- [ ] **Step 1: Write the failing studio surface test**
Add a test asserting that the output area rendering includes a warning container for partial/unavailable capability states.
Example target assertions:
```python
def test_studio_output_surfaces_capability_warnings():
html = render_chat_template_somehow(...)
assert "cap-output-note" in html
assert "renderCapabilityOutputNote" in html
```
Use the project’s existing template-string assertions if the test file is static-surface based.
- [ ] **Step 2: Run the focused studio test to verify it fails**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py::test_studio_output_surfaces_capability_warnings -q
```
Expected: FAIL.
- [ ] **Step 3: Implement the output-area warning block and helper**
In `codai/admin/templates/chat.html`:
1. Add a reusable output-note container style near existing capability card styles:
```css
.cap-output-note {
width: 100%;
max-width: 960px;
border: 1px solid rgba(245, 158, 11, .24);
background: rgba(58, 37, 16, .72);
color: #f6d08a;
border-radius: 8px;
padding: .75rem .9rem;
display: flex;
flex-direction: column;
gap: .35rem;
}
.cap-output-note.unavailable {
border-color: rgba(248, 113, 113, .24);
background: rgba(80, 28, 28, .62);
color: #f3b2b2;
}
```
2. Add a JS helper that reuses existing capability metadata:
```javascript
function renderCapabilityOutputNote(modeKey, outEl) {
const status = getCapabilityStatusForMode(modeKey);
if (!status || status.state === 'available') return;
const missing = Array.isArray(status.missing) ? status.missing : [];
const title = status.state === 'unavailable' ? 'Feature unavailable' : 'Feature partially available';
const detail = missing.length ? missing.join(', ') : 'Some required pieces are missing.';
outEl.innerHTML = `
<div class="cap-output-note ${status.state === 'unavailable' ? 'unavailable' : ''}">
<strong>${title}</strong>
<div>${detail}</div>
</div>
`;
}
```
3. Call the helper whenever a generation panel is selected or reset so the right-hand output area preserves the warning alongside empty-state content.
Use the real capability state getter names already present in `chat.html`; do not invent parallel metadata storage.
- [ ] **Step 4: Run the focused studio test to verify it passes**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py::test_studio_output_surfaces_capability_warnings -q
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add codai/admin/templates/chat.html tests/test_studio_composed_surfaces.py
git commit -m "fix: show studio capability notes in outputs"
```
## Task 5: Rebuild the minimal pipeline browser/editor
**Files:**
- Modify: `codai/admin/templates/chat.html`
- Test: `tests/test_studio_composed_surfaces.py`
- Test: any existing pipeline/admin API test files discovered during implementation
- [ ] **Step 1: Write the failing studio/pipeline surface tests**
Add assertions for the minimal pipeline flow being visibly present in the rendered studio surface.
Example assertions:
```python
def test_pipeline_tab_exposes_create_action_and_empty_state():
html = render_chat_template_somehow(...)
assert "Create pipeline" in html
assert "pipe-empty-state" in html
assert "renderPipelineList" in html
def test_pipeline_tab_exposes_editor_shell():
html = render_chat_template_somehow(...)
assert "pipe-editor" in html
assert "openPipeline" in html
```
If there is backend pipeline data support with tests, add one focused API/status test for the list payload shape as well.
- [ ] **Step 2: Run the focused pipeline tests to verify they fail**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py::test_pipeline_tab_exposes_create_action_and_empty_state -q
pytest tests/test_studio_composed_surfaces.py::test_pipeline_tab_exposes_editor_shell -q
```
Expected: FAIL.
- [ ] **Step 3: Implement the pipeline browse/create/select/edit shell**
In `codai/admin/templates/chat.html`, add:
1. A visible pipeline toolbar with a primary create button.
2. An explicit empty-state container.
3. A pipeline list container rendering cards/items for existing pipelines.
4. A details/editor area that opens when a pipeline is selected.
5. Focused helper functions for local state and rendering.
Minimum structure to add:
```html
<div class="pipe-toolbar">
<button class="btn btn-primary" onclick="createPipeline()">Create pipeline</button>
<div class="muted small" id="pipe-status"></div>
</div>
<div class="pipe-shell">
<div class="pipe-list" id="pipe-list"></div>
<div class="pipe-editor" id="pipe-editor">
<div class="pipe-empty-state" id="pipe-empty-state">No pipeline selected.</div>
</div>
</div>
```
Minimum helper shape:
```javascript
const pipelineState = {
items: [],
selectedId: null,
};
function createPipeline() { /* create local/default pipeline model or call existing backend hook */ }
function openPipeline(id) { /* select one pipeline and rerender */ }
function renderPipelineList() { /* render cards or empty state */ }
function renderPipelineEditor() { /* render selected pipeline steps and fields */ }
```
Use the existing backend/data hooks already present in the file if they exist; otherwise keep the shell honest with visible empty-state and minimal editable local state.
- [ ] **Step 4: Run the focused pipeline tests to verify they pass**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py::test_pipeline_tab_exposes_create_action_and_empty_state -q
pytest tests/test_studio_composed_surfaces.py::test_pipeline_tab_exposes_editor_shell -q
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add codai/admin/templates/chat.html tests/test_studio_composed_surfaces.py tests/<any-pipeline-test-file>.py
git commit -m "feat: restore minimal studio pipeline editor"
```
## Task 6: Full verification and final integration pass
**Files:**
- Modify: any of the above if verification reveals issues
- Test: all relevant focused and broader checks
- [ ] **Step 1: Run focused tests for all touched areas**
Run:
```bash
pytest tests/test_studio_composed_surfaces.py -q
pytest tests/<path-to-admin-or-routes-test>.py -q
```
Expected: PASS.
- [ ] **Step 2: Run project lint and typecheck commands**
Determine the repository’s actual commands from existing project config/docs, then run them exactly. Example placeholders to replace with real commands once discovered:
```bash
<real-lint-command>
<real-typecheck-command>
```
Expected: PASS.
- [ ] **Step 3: Run any additional targeted regression tests**
Run the backend or admin test groups most closely related to routes, request logging, and studio surfaces. Example:
```bash
pytest tests -q
```
Only use a broader suite if it is practical in this repository.
- [ ] **Step 4: Fix any failures uncovered and rerun the affected commands**
For each failure:
```text
- adjust only the relevant file
- rerun the smallest failing test first
- rerun lint/typecheck if the fix touched Python or shared frontend template logic
```
Expected: all previously failing checks pass.
- [ ] **Step 5: Commit**
```bash
git add codai/admin/static/style.css codai/api/log.py codai/admin/routes.py codai/admin/templates/dashboard.html codai/admin/templates/chat.html tests
git commit -m "fix: polish admin studio experience"
```
## Self-Review
### Spec coverage check
- Dark-theme controls and scrollbars: covered by Task 1.
- Overview recent activity recovery: covered by Task 2.
- Wider studio split-pane controls: covered by Task 3.
- Output-area partial/unavailable notes: covered by Task 4.
- Minimal pipeline browser/editor rebuild: covered by Task 5.
- Verification, lint, typecheck, and regression checks: covered by Task 6.
### Placeholder scan
- Remaining placeholders intentionally require discovery of exact existing backend test file paths and exact project lint/typecheck commands before execution.
- These must be resolved during implementation by inspecting the existing repo structure before code changes are finalized.
### Type consistency check
- `recent_activity` row shape is consistently defined as `time`, `model`, `type`, `status`, `duration`.
- Capability output helper uses `state` and `missing` naming consistently.
- Pipeline state consistently uses `items` and `selectedId`.
"""The admin UI must actually reach every setting — no JSON editing.
Two failure modes this catches, both invisible until someone tries to use the
page: a getElementById() for an id that no longer exists (silently reads/writes
nothing), and a form field the save path never collects (edits vanish on save).
"""
import re
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
TPL = ROOT / "codai" / "admin" / "templates"
def _ids_defined(html: str) -> set:
"""Ids in the markup, plus ones the page assigns to elements it creates."""
static = set(re.findall(r'\bid="([A-Za-z0-9_\-]+)"', html))
dynamic = set(re.findall(r"\.id\s*=\s*'([A-Za-z0-9_\-]+)'", html))
return static | dynamic
def _ids_referenced(html: str) -> set:
"""Only literal getElementById('x') calls — dynamic ids are built per row."""
return set(re.findall(r"getElementById\(\s*'([A-Za-z0-9_\-]+)'\s*\)", html))
@pytest.mark.parametrize("page", ["settings.html", "models.html"])
def test_every_referenced_element_exists(page):
html = (TPL / page).read_text()
missing = sorted(_ids_referenced(html) - _ids_defined(html))
assert missing == [], f"{page} reads ids that no element defines: {missing}"
def test_model_page_exposes_every_placement_setting():
html = (TPL / "models.html").read_text()
ids = _ids_defined(html)
for want in (
# serve this model from elsewhere
"cfg-remote-url", "cfg-remote-served", "cfg-remote-key",
# burst to RunPod
"cfg-spill-enabled", "cfg-spill-busy", "cfg-spill-queue",
"cfg-spill-nogpu", "cfg-spill-mode", "cfg-spill-endpoint",
"cfg-spill-engine", "cfg-spill-hf-gguf", "cfg-spill-image",
# pod shape, auth and scaling
"cfg-rp-engine", "cfg-rp-hf-gguf", "cfg-rp-api-key", "cfg-rp-open",
"cfg-rp-registry-auth", "cfg-rp-health-path", "cfg-rp-docker-args",
"cfg-rp-scale-up", "cfg-rp-max-inflight", "cfg-rp-sticky"):
assert want in ids, f"models.html has no field for {want}"
def test_model_page_saves_what_it_shows():
"""Every new field must be read back by the collect functions."""
html = (TPL / "models.html").read_text()
for want in ("cfg-remote-url", "cfg-spill-enabled", "cfg-spill-busy",
"cfg-rp-engine", "cfg-rp-api-key", "cfg-rp-open",
"cfg-rp-scale-up", "cfg-rp-max-inflight", "cfg-rp-sticky",
"cfg-rp-registry-auth", "cfg-rp-hf-gguf"):
assert html.count(f"'{want}'") >= 2, \
f"{want} is shown but never collected (or never populated)"
def test_settings_page_exposes_engine_service_urls_and_remotes():
html = (TPL / "settings.html").read_text()
ids = _ids_defined(html)
for engine in ("ds4", "colibri", "k3", "kt", "vllm"):
assert f"s-{engine}-service-url" in ids, f"no service URL field for {engine}"
assert html.count(f"'s-{engine}-service-url'") >= 2, \
f"{engine} service URL is shown but not loaded+saved"
for want in ("s-runpod-registry-auth", "s-remotes-enabled", "s-remotes-key",
"s-remotes-max-body", "s-remotes-rows"):
assert want in ids, f"settings.html has no field for {want}"
assert "_collectRemotes()" in html and "_populateRemotes(" in html
def test_every_gateway_capability_has_a_row_in_the_ui():
"""A capability the gateway can route but the UI cannot set would be
configurable only by editing JSON — exactly what this must avoid."""
from codai.api.remote_gateway import _CAPABILITY_PREFIXES
html = (TPL / "settings.html").read_text()
listed = set(re.findall(r"\['([a-z_]+)','[^']+','[^']*'\]", html))
routable = {cap for _, cap in _CAPABILITY_PREFIXES}
missing = sorted(routable - listed)
assert missing == [], f"capabilities with no UI row: {missing}"
def test_admin_accepts_every_pod_field_the_ui_sends():
"""The form can only be as good as the keys the backend keeps."""
routes = (ROOT / "codai" / "admin" / "routes.py").read_text()
for key in ("engine", "hf_gguf", "health_path", "docker_args",
"registry_auth_id", "api_key", "allow_open_pod",
"sticky_sessions", "max_inflight_per_pod", "on_busy",
"service_url", "served_model"):
assert f'"{key}"' in routes, f"admin API drops the {key} field"
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