feat(tools): single-file gateway chat playground
Zero-dependency, zero-backend HTML chat UI for the LiteLLM gateway. The browser talks straight to :4000 (gateway CORS is open), so it's just one file you open — no container, no stack. System-prompt textarea, model datalist, streaming SSE, renders reasoning_content for the -thinking/ -reasoning models, settings persist in localStorage. Deliberately never sends a `tools` field, sidestepping the vLLM "tools must not be an empty array" bug that breaks the LiteLLM admin UI playground for vLLM-backed models (litellm #6228; the gateway's strip_empty_tools hook can't reach the UI's in-process completion call). Verified against the live gateway: streams + parses a real completion with no tools sent.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Gateway Chat — light LiteLLM playground</title>
|
||||
<!--
|
||||
Single-file chat playground for the LiteLLM gateway (10.250.50.70:4000).
|
||||
Zero dependencies, zero backend: open it in a browser (file:// is fine) or
|
||||
serve statically. The gateway sends permissive CORS, so the browser talks to
|
||||
it directly. It deliberately NEVER sends a `tools` field, sidestepping the
|
||||
vLLM "tools must not be an empty array" bug that breaks the LiteLLM UI
|
||||
playground for vLLM-backed models. Settings persist in localStorage.
|
||||
-->
|
||||
<style>
|
||||
:root{--bg:#0f1115;--panel:#171a21;--ink:#e6e9ef;--muted:#8b93a7;--acc:#5b8cff;--line:#262b36;--user:#1f6feb22;--warn:#ff6b6b}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;font:14px/1.55 system-ui,sans-serif;background:var(--bg);color:var(--ink);display:flex;height:100vh}
|
||||
#side{width:330px;min-width:330px;background:var(--panel);border-right:1px solid var(--line);padding:14px;overflow:auto}
|
||||
#main{flex:1;display:flex;flex-direction:column;min-width:0}
|
||||
h1{font-size:13px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);margin:0 0 12px}
|
||||
label{display:block;font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin:10px 0 4px}
|
||||
input,textarea,select{width:100%;background:#0c0e13;color:var(--ink);border:1px solid var(--line);border-radius:6px;padding:7px 8px;font:inherit}
|
||||
textarea{resize:vertical}
|
||||
#sys{min-height:130px;font-family:ui-monospace,monospace;font-size:12px}
|
||||
.row{display:flex;gap:8px}.row>*{flex:1}
|
||||
#log{flex:1;overflow:auto;padding:18px;display:flex;flex-direction:column;gap:14px}
|
||||
.msg{max-width:840px}
|
||||
.msg .who{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted);margin-bottom:3px}
|
||||
.msg .body{white-space:pre-wrap;word-wrap:break-word}
|
||||
.msg.user .body{background:var(--user);border:1px solid #1f6feb55;border-radius:8px;padding:8px 10px}
|
||||
.msg .reason{white-space:pre-wrap;color:var(--muted);font-size:12.5px;border-left:2px solid var(--line);padding-left:10px;margin-bottom:6px}
|
||||
#composer{border-top:1px solid var(--line);padding:12px 18px;display:flex;gap:10px;background:var(--panel)}
|
||||
#inp{flex:1;min-height:42px;max-height:220px}
|
||||
button{background:var(--acc);color:#fff;border:0;border-radius:6px;padding:0 16px;font:inherit;font-weight:600;cursor:pointer}
|
||||
button.ghost{background:transparent;color:var(--muted);border:1px solid var(--line)}
|
||||
button:disabled{opacity:.5;cursor:default}
|
||||
.err{color:var(--warn);font-family:ui-monospace,monospace;font-size:12px;white-space:pre-wrap}
|
||||
.hint{font-size:11px;color:var(--muted);margin-top:8px}
|
||||
code{font-family:ui-monospace,monospace}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<aside id="side">
|
||||
<h1>Gateway Chat</h1>
|
||||
<label>Base URL</label>
|
||||
<input id="base" placeholder="http://10.250.50.70:4000/v1">
|
||||
<label>API key</label>
|
||||
<input id="key" type="password" placeholder="sk-…">
|
||||
<label>Model</label>
|
||||
<input id="model" list="models" placeholder="mistral-small-4">
|
||||
<datalist id="models">
|
||||
<option>granite-4.1-8b</option>
|
||||
<option>mistral-small-4</option>
|
||||
<option>mistral-small-4-reasoning</option>
|
||||
<option>qwen3.6-35b-a3b</option>
|
||||
<option>qwen3.6-35b-a3b-thinking</option>
|
||||
<option>selene-1-mini-8b</option>
|
||||
<option>glm-5.1</option>
|
||||
<option>glm-4.7</option>
|
||||
</datalist>
|
||||
<label>System prompt</label>
|
||||
<textarea id="sys" placeholder="You are a helpful assistant."></textarea>
|
||||
<div class="row">
|
||||
<div><label>Temperature</label><input id="temp" type="number" step="0.05" value="1"></div>
|
||||
<div><label>Max tokens</label><input id="max" type="number" step="1" value="1024"></div>
|
||||
</div>
|
||||
<div class="hint">Settings persist locally. No <code>tools</code> field is ever sent. Reasoning models show their <code>reasoning_content</code> above the answer.</div>
|
||||
</aside>
|
||||
<main id="main">
|
||||
<div id="log"></div>
|
||||
<div id="composer">
|
||||
<textarea id="inp" placeholder="Message… (Enter to send · Shift+Enter for newline)"></textarea>
|
||||
<button id="send">Send</button>
|
||||
<button id="reset" class="ghost" title="Clear conversation">Reset</button>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const cfg = ['base','key','model','sys','temp','max'];
|
||||
cfg.forEach(k => { const v = localStorage.getItem('gc_'+k); if (v !== null) $(k).value = v; });
|
||||
if (!$('base').value) $('base').value = 'http://10.250.50.70:4000/v1';
|
||||
cfg.forEach(k => $(k).addEventListener('input', e => localStorage.setItem('gc_'+k, e.target.value)));
|
||||
|
||||
let history = []; // [{role:'user'|'assistant', content}]
|
||||
const log = $('log');
|
||||
|
||||
function addMsg(who, cls){
|
||||
const wrap = document.createElement('div'); wrap.className = 'msg ' + cls;
|
||||
const h = document.createElement('div'); h.className = 'who'; h.textContent = who;
|
||||
const r = document.createElement('div'); r.className = 'reason'; r.style.display = 'none';
|
||||
const b = document.createElement('div'); b.className = 'body';
|
||||
wrap.append(h, r, b); log.append(wrap); log.scrollTop = log.scrollHeight;
|
||||
return { wrap, reason:r, body:b };
|
||||
}
|
||||
function showErr(text){ const m = addMsg('error','err'); m.body.className = 'err'; m.body.textContent = text; }
|
||||
|
||||
async function send(){
|
||||
const text = $('inp').value.trim(); if (!text) return;
|
||||
$('inp').value = '';
|
||||
history.push({ role:'user', content:text });
|
||||
addMsg('you','user').body.textContent = text;
|
||||
|
||||
const messages = [];
|
||||
const sys = $('sys').value.trim();
|
||||
if (sys) messages.push({ role:'system', content:sys });
|
||||
messages.push(...history);
|
||||
|
||||
const body = {
|
||||
model: $('model').value.trim() || 'mistral-small-4',
|
||||
messages,
|
||||
temperature: parseFloat($('temp').value),
|
||||
max_tokens: parseInt($('max').value, 10),
|
||||
stream: true
|
||||
// intentionally NO `tools` — vLLM 400s on an empty array.
|
||||
};
|
||||
|
||||
const out = addMsg(body.model, 'assistant');
|
||||
let content = '', reasoning = '';
|
||||
$('send').disabled = true;
|
||||
try {
|
||||
const res = await fetch($('base').value.replace(/\/+$/,'') + '/chat/completions', {
|
||||
method:'POST',
|
||||
headers:{ 'Content-Type':'application/json', 'Authorization':'Bearer ' + $('key').value },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
if (!res.ok){ showErr('HTTP ' + res.status + '\n' + await res.text()); out.wrap.remove(); return; }
|
||||
const reader = res.body.getReader(); const dec = new TextDecoder(); let buf = '';
|
||||
for(;;){
|
||||
const { done, value } = await reader.read(); if (done) break;
|
||||
buf += dec.decode(value, { stream:true });
|
||||
const lines = buf.split('\n'); buf = lines.pop();
|
||||
for (const line of lines){
|
||||
const t = line.trim(); if (!t.startsWith('data:')) continue;
|
||||
const payload = t.slice(5).trim(); if (payload === '[DONE]') continue;
|
||||
let j; try { j = JSON.parse(payload); } catch { continue; }
|
||||
const d = (j.choices && j.choices[0] && j.choices[0].delta) || {};
|
||||
if (d.reasoning_content){ reasoning += d.reasoning_content; out.reason.style.display = 'block'; out.reason.textContent = reasoning; }
|
||||
if (d.content){ content += d.content; out.body.textContent = content; }
|
||||
log.scrollTop = log.scrollHeight;
|
||||
}
|
||||
}
|
||||
history.push({ role:'assistant', content });
|
||||
} catch (e){
|
||||
showErr(String(e)); out.wrap.remove();
|
||||
} finally {
|
||||
$('send').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
$('send').addEventListener('click', send);
|
||||
$('inp').addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey){ e.preventDefault(); send(); } });
|
||||
$('reset').addEventListener('click', () => { history = []; log.innerHTML = ''; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user