Add a recorded demo, and the script that produces it
scripts/demo.sh is a real walkthrough: it starts the gateway, routes model:auto, resolves four aliases to four different models, shows a cache hit costing zero quota, recalls a name across a server-side session, 404s an unknown model with suggestions, and prints where the tokens went. Nothing is staged — every line is output the gateway actually produced against a live provider. scripts/record-demo.html turns a captured transcript into video frames. ffmpeg on macOS ships without freetype, so it can encode frames but cannot draw text; the browser can do both, so frames are rasterised on a canvas and posted to a local server for ffmpeg to assemble. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b85542abc6
commit
eeb5cad4ce
7 files changed
+295
No files matched your search
@@ -14,3 +14,6 @@ data/
|
||||
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# recorder scratch — the encoded demo is committed, the frames are not
|
||||
demo/frames/
|
||||
@@ -8,6 +8,11 @@ Your apps point at `http://localhost:8787/v1` and never see a provider key. When
|
||||
one free tier runs out, the next one takes over — mid-conversation, even if it
|
||||
has a sixteenth of the context window.
|
||||
|
||||

|
||||
|
||||
*Every frame above is a real request against a live provider — recorded by
|
||||
`./scripts/demo.sh`, which you can run yourself.*
|
||||
|
||||
```
|
||||
your app ──▶ Freeway ──┬──▶ Mistral 1B tokens/month
|
||||
├──▶ Cloudflare 10k neurons/day
|
||||
@@ -399,6 +404,7 @@ gateway flatly refuses to bind one with no keys at all.
|
||||
## Development
|
||||
|
||||
```bash
|
||||
./scripts/demo.sh # scripted walkthrough against your real providers
|
||||
npm run build # tsc --build, must be clean
|
||||
npm test # node:test across all workspaces
|
||||
npm run smoke # every route against mock upstreams — run this after adding a provider
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 255 KiB |
Executable
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env bash
|
||||
# A scripted walkthrough of Freeway against real providers.
|
||||
#
|
||||
# ./scripts/demo.sh # narrate + run
|
||||
# ./scripts/demo.sh --transcript # same, but machine-readable for recording
|
||||
#
|
||||
# Everything here is a real request. Nothing is faked, and the output you see is
|
||||
# the output the gateway actually produced.
|
||||
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PORT="${DEMO_PORT:-8790}"
|
||||
B="http://127.0.0.1:$PORT"
|
||||
TRANSCRIPT=""
|
||||
[[ "${1:-}" == "--transcript" ]] && TRANSCRIPT=1
|
||||
|
||||
if [[ -n "$TRANSCRIPT" ]]; then
|
||||
C_DIM=''; C_B=''; C_G=''; C_C=''; C_Y=''; C_X=''
|
||||
else
|
||||
C_DIM=$'\033[2m'; C_B=$'\033[1m'; C_G=$'\033[32m'; C_C=$'\033[36m'; C_Y=$'\033[33m'; C_X=$'\033[0m'
|
||||
fi
|
||||
|
||||
say() { printf '\n%s# %s%s\n' "$C_DIM" "$1" "$C_X"; }
|
||||
cmd() { printf '%s$ %s%s\n' "$C_C" "$1" "$C_X"; }
|
||||
pause(){ [[ -n "$TRANSCRIPT" ]] || sleep "${1:-1}"; }
|
||||
|
||||
cleanup() { [[ -n "${GW_PID:-}" ]] && kill "$GW_PID" 2>/dev/null; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
printf '%s┌─ Freeway ─ one endpoint in front of every free LLM tier%s\n' "$C_B" "$C_X"
|
||||
|
||||
say "Start the gateway. No keys? It still starts — every provider becomes a card telling you what to set."
|
||||
cmd "npm start"
|
||||
PORT=$PORT node packages/gateway/dist/bin.js > /tmp/freeway-demo.log 2>&1 &
|
||||
GW_PID=$!
|
||||
for _ in $(seq 1 30); do curl -fsS "$B/healthz" >/dev/null 2>&1 && break; sleep 0.4; done
|
||||
grep -E "listening|✓|○" /tmp/freeway-demo.log | head -6
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "model:\"auto\" — you don't name a vendor. Freeway picks whoever is healthy and under quota."
|
||||
cmd "curl \$B/v1/chat/completions -d '{\"model\":\"auto\",\"messages\":[…]}' -D-"
|
||||
curl -sS "$B/v1/chat/completions" -H 'content-type: application/json' \
|
||||
-d '{"model":"auto","messages":[{"role":"user","content":"In one sentence: what is a rate limit?"}],"max_tokens":60}' \
|
||||
-D /tmp/h -o /tmp/b
|
||||
grep -iE "^HTTP|^x-freeway" /tmp/h | sed "s/^/ ${C_G}/;s/\$/${C_X}/"
|
||||
printf ' %s→%s %s\n' "$C_G" "$C_X" "$(node -e "
|
||||
const j=JSON.parse(require('fs').readFileSync('/tmp/b','utf8'));
|
||||
console.log(j.choices[0].message.content.replace(/\n/g,' ').slice(0,150));")"
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Aliases route by capability, not vendor. Same call, four different models."
|
||||
for m in fast cheap code reason; do
|
||||
curl -sS -o /tmp/b -D /tmp/h "$B/v1/chat/completions" -H 'content-type: application/json' \
|
||||
-d "{\"model\":\"$m\",\"messages\":[{\"role\":\"user\",\"content\":\"reply with the single word: ok\"}],\"max_tokens\":8}"
|
||||
printf ' %-7s → %-24s %s\n' "$m" \
|
||||
"$(grep -i '^x-freeway-model' /tmp/h | cut -d' ' -f2 | tr -d '\r')" \
|
||||
"$(grep -i '^x-freeway-ms' /tmp/h | cut -d' ' -f2 | tr -d '\r')ms"
|
||||
sleep 0.7
|
||||
done
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Ask the same thing twice and the second is free — served from cache, zero quota."
|
||||
Q='{"model":"fast","temperature":0,"messages":[{"role":"user","content":"what is 2+2?"}],"max_tokens":20}'
|
||||
curl -sS -o /dev/null "$B/v1/chat/completions" -H 'content-type: application/json' -d "$Q"
|
||||
curl -sS -o /dev/null -D /tmp/h "$B/v1/chat/completions" -H 'content-type: application/json' -d "$Q"
|
||||
printf ' %scache: %s provider: %s %s0 tokens spent%s\n' "$C_G" \
|
||||
"$(grep -i '^x-freeway-cache' /tmp/h | cut -d' ' -f2 | tr -d '\r')" \
|
||||
"$(grep -i '^x-freeway-provider' /tmp/h | cut -d' ' -f2 | tr -d '\r')" "$C_B" "$C_X"
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Sessions live server-side. Send only the new message; the gateway rebuilds the thread."
|
||||
cmd "-d '{\"session\":\"demo\",\"messages\":[{\"content\":\"my name is Ada\"}]}'"
|
||||
curl -sS -o /dev/null "$B/v1/chat/completions" -H 'content-type: application/json' \
|
||||
-d '{"model":"fast","session":"demo","messages":[{"role":"user","content":"My name is Ada. Remember it."}],"max_tokens":30}'
|
||||
cmd "-d '{\"session\":\"demo\",\"messages\":[{\"content\":\"what is my name?\"}]}'"
|
||||
curl -sS -o /tmp/b -D /tmp/h "$B/v1/chat/completions" -H 'content-type: application/json' \
|
||||
-d '{"model":"fast","session":"demo","messages":[{"role":"user","content":"What is my name?"}],"max_tokens":30}'
|
||||
printf ' %s→%s %s\n' "$C_G" "$C_X" "$(node -e "
|
||||
const j=JSON.parse(require('fs').readFileSync('/tmp/b','utf8'));
|
||||
console.log(j.choices[0].message.content.replace(/\n/g,' ').slice(0,120));")"
|
||||
printf ' %s%s%s\n' "$C_DIM" "$(grep -i '^x-freeway-context' /tmp/h | tr -d '\r')" "$C_X"
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Ask for a model that doesn't exist. You get a 404 that tells you what does."
|
||||
curl -sS "$B/v1/chat/completions" -H 'content-type: application/json' \
|
||||
-d '{"model":"gpt-5-turbo-ultra","messages":[{"role":"user","content":"hi"}]}' \
|
||||
| node -e "let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s);
|
||||
console.log(' '+j.error.message);
|
||||
console.log(' did you mean: '+j.freeway.suggestions.slice(0,3).join(', '));});"
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Where did the tokens go?"
|
||||
curl -sS "$B/api/usage" | node -e "
|
||||
let s='';process.stdin.on('data',d=>s+=d).on('end',()=>{const j=JSON.parse(s),t=j.totals;
|
||||
console.log(' '+t.tokensIn+' in / '+t.tokensOut+' out over '+t.requests+' requests, '+t.avgMs+'ms avg');
|
||||
console.log(' '+t.cached+' served from cache · '+t.failed+' failed');
|
||||
for (const m of j.byModel) console.log(' '+m.id.padEnd(26)+(m.tokensIn+m.tokensOut)+' tok ×'+m.requests);});"
|
||||
pause 2
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
say "Adding a provider is one JSON file. Never a code change."
|
||||
cmd "node scripts/verify-provider.mjs mistral"
|
||||
node scripts/verify-provider.mjs mistral 2>&1 | sed -n '3,14p' | sed "s/^/ /"
|
||||
|
||||
printf '\n%s└─ dashboard at %s · 12 providers bundled · zero runtime dependencies%s\n\n' "$C_B" "$B" "$C_X"
|
||||
@@ -0,0 +1,173 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>Freeway — demo recorder</title></head>
|
||||
<body style="margin:0;background:#111;color:#ccc;font:13px system-ui">
|
||||
<!--
|
||||
Renders a captured demo transcript into video frames on a <canvas>.
|
||||
|
||||
ffmpeg on macOS ships without freetype, so it can encode frames but cannot
|
||||
draw text. The browser can do both, so frames are rasterised here and handed
|
||||
back as PNGs for ffmpeg to assemble.
|
||||
|
||||
Usage: open, paste the transcript into TRANSCRIPT, then call
|
||||
window.renderAll() and window.getFrame(i).
|
||||
-->
|
||||
<div style="padding:10px">
|
||||
<div id="status">idle</div>
|
||||
<canvas id="c" width="960" height="600" style="border:1px solid #333;max-width:100%"></canvas>
|
||||
</div>
|
||||
<script>
|
||||
'use strict';
|
||||
|
||||
const W = 960, H = 600, PAD = 22;
|
||||
const LINE = 17, FONT = '13px Menlo, monospace';
|
||||
const VISIBLE = Math.floor((H - PAD * 2 - 34) / LINE);
|
||||
|
||||
const COL = {
|
||||
bg: '#0a0c0f', chrome: '#12151a', line: '#242a33',
|
||||
fg: '#dbe2ea', dim: '#69737f', cyan: '#58a6ff',
|
||||
green: '#3fb950', amber: '#d29922', purple: '#bc8cff', red: '#f85149',
|
||||
};
|
||||
|
||||
let TRANSCRIPT = [];
|
||||
const canvas = document.getElementById('c');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
/** Colour a line by what it is, matching the dashboard's palette. */
|
||||
function styleOf(text) {
|
||||
if (/^┌─|^└─/.test(text)) return { c: COL.fg, bold: true };
|
||||
if (/^\s*#/.test(text)) return { c: COL.dim, bold: false };
|
||||
if (/^\s*\$/.test(text)) return { c: COL.cyan, bold: false };
|
||||
if (/^\s*(✓|→)/.test(text)) return { c: COL.green, bold: false };
|
||||
if (/^\s*○/.test(text)) return { c: COL.amber, bold: false };
|
||||
if (/x-freeway-(provider|model)/.test(text)) return { c: COL.green, bold: true };
|
||||
if (/^\s*x-freeway|^\s*HTTP/.test(text)) return { c: COL.green, bold: false };
|
||||
if (/cache: exact|0 tokens spent/.test(text)) return { c: COL.purple, bold: true };
|
||||
if (/failed|unknown model/.test(text)) return { c: COL.amber, bold: false };
|
||||
return { c: COL.fg, bold: false };
|
||||
}
|
||||
|
||||
function drawChrome() {
|
||||
ctx.fillStyle = COL.bg;
|
||||
ctx.fillRect(0, 0, W, H);
|
||||
ctx.fillStyle = COL.chrome;
|
||||
ctx.fillRect(0, 0, W, 34);
|
||||
ctx.strokeStyle = COL.line;
|
||||
ctx.beginPath(); ctx.moveTo(0, 34.5); ctx.lineTo(W, 34.5); ctx.stroke();
|
||||
|
||||
const dots = ['#f85149', '#d29922', '#3fb950'];
|
||||
dots.forEach((c, i) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(20 + i * 17, 17, 5.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = c;
|
||||
ctx.fill();
|
||||
});
|
||||
|
||||
ctx.font = '12px Menlo, monospace';
|
||||
ctx.fillStyle = COL.dim;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('freeway — demo', W / 2, 21);
|
||||
ctx.textAlign = 'left';
|
||||
}
|
||||
|
||||
/**
|
||||
* One frame: every line up to `upto`, with the last one revealed `partial`
|
||||
* characters in, so text appears to be typing rather than blinking into place.
|
||||
*/
|
||||
function drawFrame(upto, partial) {
|
||||
drawChrome();
|
||||
ctx.font = FONT;
|
||||
ctx.textBaseline = 'top';
|
||||
|
||||
const start = Math.max(0, upto - VISIBLE + 1);
|
||||
let y = 34 + PAD;
|
||||
|
||||
for (let i = start; i <= upto && i < TRANSCRIPT.length; i++) {
|
||||
let text = TRANSCRIPT[i];
|
||||
const last = i === upto;
|
||||
if (last && partial !== undefined && partial < text.length) text = text.slice(0, partial);
|
||||
|
||||
const s = styleOf(TRANSCRIPT[i]);
|
||||
ctx.font = (s.bold ? 'bold ' : '') + FONT;
|
||||
ctx.fillStyle = s.c;
|
||||
|
||||
// Clip to the window rather than letting a long line run off the edge.
|
||||
let shown = text.replace(/\t/g, ' ');
|
||||
const maxW = W - PAD * 2 - 12;
|
||||
if (ctx.measureText(shown).width > maxW) {
|
||||
while (shown.length > 1 && ctx.measureText(shown + '…').width > maxW) shown = shown.slice(0, -1);
|
||||
shown += '…';
|
||||
}
|
||||
ctx.fillText(shown, PAD, y);
|
||||
|
||||
if (last) {
|
||||
// Block cursor, so a paused frame still reads as a live terminal.
|
||||
const w = ctx.measureText(shown).width;
|
||||
ctx.fillStyle = COL.green;
|
||||
ctx.fillRect(PAD + w + 2, y + 1, 7, 14);
|
||||
}
|
||||
y += LINE;
|
||||
}
|
||||
}
|
||||
|
||||
/* Frames are precomputed so ffmpeg gets a deterministic sequence rather than
|
||||
whatever the render loop happened to catch. */
|
||||
let FRAMES = [];
|
||||
|
||||
window.buildFrames = function (transcript) {
|
||||
TRANSCRIPT = transcript.split('\n').map((l) => l.replace(/\[[0-9;]*m/g, ''));
|
||||
// Trim leading/trailing blank runs, keep interior spacing.
|
||||
while (TRANSCRIPT.length && !TRANSCRIPT[0].trim()) TRANSCRIPT.shift();
|
||||
while (TRANSCRIPT.length && !TRANSCRIPT[TRANSCRIPT.length - 1].trim()) TRANSCRIPT.pop();
|
||||
|
||||
FRAMES = [];
|
||||
for (let i = 0; i < TRANSCRIPT.length; i++) {
|
||||
const text = TRANSCRIPT[i];
|
||||
// A comment line is narration — type it out. Everything else is output,
|
||||
// which in a real terminal appears at once.
|
||||
if (/^\s*#/.test(text) && text.length > 12) {
|
||||
const steps = 3;
|
||||
for (let s = 1; s <= steps; s++) FRAMES.push({ upto: i, partial: Math.ceil((text.length / steps) * s) });
|
||||
} else {
|
||||
FRAMES.push({ upto: i, partial: undefined });
|
||||
}
|
||||
// Hold on the lines worth reading.
|
||||
if (/x-freeway-provider|cache: exact|→ Your name is|└─/.test(text)) {
|
||||
for (let h = 0; h < 4; h++) FRAMES.push({ upto: i, partial: undefined });
|
||||
}
|
||||
}
|
||||
for (let h = 0; h < 8; h++) FRAMES.push({ upto: TRANSCRIPT.length - 1, partial: undefined });
|
||||
|
||||
document.getElementById('status').textContent = `${TRANSCRIPT.length} lines → ${FRAMES.length} frames`;
|
||||
return FRAMES.length;
|
||||
};
|
||||
|
||||
window.frameCount = () => FRAMES.length;
|
||||
|
||||
/** Render frame `i` and return it as a bare base64 PNG. */
|
||||
window.getFrame = function (i) {
|
||||
const f = FRAMES[i];
|
||||
drawFrame(f.upto, f.partial);
|
||||
return canvas.toDataURL('image/png').split(',')[1];
|
||||
};
|
||||
|
||||
/** Render a contiguous batch, so the whole sequence needs few round trips. */
|
||||
window.getBatch = function (from, count) {
|
||||
const out = [];
|
||||
for (let i = from; i < Math.min(from + count, FRAMES.length); i++) out.push(window.getFrame(i));
|
||||
return out;
|
||||
};
|
||||
|
||||
window.preview = function () {
|
||||
let i = 0;
|
||||
const tick = () => {
|
||||
if (i >= FRAMES.length) return;
|
||||
const f = FRAMES[i++];
|
||||
drawFrame(f.upto, f.partial);
|
||||
setTimeout(tick, 120);
|
||||
};
|
||||
tick();
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user