lab09-lite / engine.js
richardyoung's picture
Lab 9 Lite: an agent is a model in a loop. No code
fc75aa2 verified
Raw History Blame Contribute Delete
17.7 kB
/* MIS 752 Lite labs: shared engine. Every no-code lab page loads this, then its own lab.js.
*
* Runs entirely in the student's browser: the page calls OpenRouter directly with the key the
* student pastes, so the key never passes through any server and is never saved (it lives only in
* this page's memory and is gone when the tab closes). OpenRouter allows these browser calls (CORS).
* When the page is opened from the instructor's own computer (localhost) with no key, it uses the
* local Ollama model instead, so the instructor can demo with no account.
*/
"use strict";
const OR = "https://openrouter.ai/api/v1";
const Lite = {
key: "",
model: "",
showPaid: false,
free: [],
paid: [],
LOCAL_MODEL: "qwen2.5:3b-instruct",
isLocal: ["localhost", "127.0.0.1"].includes(location.hostname),
FREE_PREFERRED: ["qwen/qwen3.8-27b:free", "google/gemma-4-31b-it:free"],
/* "Show paid models too" lists only these, not OpenRouter's few hundred: low-cost models from well-known
* makers that take tool calls. Starred ones come first; the first one is the default. Prices are read live
* from OpenRouter, and a model that disappears or stops taking tools drops out of the list by itself.
* Not GPT-3.5: it costs more than every model here, and OpenAI replaced it with GPT-4o mini in July 2024. */
RECOMMENDED_PAID: [
{id: "openai/gpt-4o-mini", name: "GPT-4o mini", maker: "OpenAI", star: true, why: "replaced GPT-3.5 in ChatGPT; cheaper and better with tools"},
{id: "google/gemini-2.5-flash-lite", name: "Gemini 2.5 Flash-Lite", maker: "Google", star: true, why: "fast and low-cost"},
{id: "qwen/qwen3-235b-a22b-2507", name: "Qwen3 235B", maker: "Alibaba", star: true, why: "a large open model, strong with tools"},
{id: "deepseek/deepseek-v4-flash", name: "DeepSeek V4 Flash", maker: "DeepSeek", why: "DeepSeek's low-cost model"},
{id: "openai/gpt-4.1-nano", name: "GPT-4.1 nano", maker: "OpenAI", why: "OpenAI's smallest quick model"},
{id: "mistralai/mistral-small-3.2-24b-instruct", name: "Mistral Small 3.2", maker: "Mistral AI", why: "a small open model from France"},
{id: "openai/gpt-4.1-mini", name: "GPT-4.1 mini", maker: "OpenAI", why: "a step up; costs a little more, very dependable with tools"},
],
// Rough size of one full pass through a lab (both sides of every example, plus a few retries), for the cost estimate.
LAB_TOKENS: {in: 25000, out: 5000},
esc(s) {
return String(s ?? "").replace(/[&<>"']/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
},
sleep(ms) { return new Promise(r => setTimeout(r, ms)); },
$(sel, root = document) { return root.querySelector(sel); },
/* ---------- models and keys ---------- */
async loadModels() {
try {
const r = await fetch(OR + "/models");
const data = (await r.json()).data || [];
const tool = data.filter(m => (m.supported_parameters || []).includes("tools"));
this.free = tool.filter(m => m.id.endsWith(":free")).map(m => ({id: m.id, label: m.id}))
.sort((a, b) => a.id.localeCompare(b.id));
const live = Object.fromEntries(tool.map(m => [m.id, m]));
this.paid = this.RECOMMENDED_PAID.filter(r => live[r.id]).map(r => this.priced(r, live[r.id].pricing));
} catch (e) {
this.free = this.FREE_PREFERRED.map(id => ({id, label: id}));
this.paid = [];
}
if (!this.free.length) this.free = this.FREE_PREFERRED.map(id => ({id, label: id}));
if (!this.paid.length) this.paid = this.RECOMMENDED_PAID.map(r => this.priced(r, null));
},
/* One shortlist entry with its live price and what a whole lab costs on it, in plain words. */
priced(r, pricing) {
const pin = parseFloat(pricing && pricing.prompt) * 1e6, pout = parseFloat(pricing && pricing.completion) * 1e6;
const known = isFinite(pin) && isFinite(pout);
const cents = (pin * this.LAB_TOKENS.in + pout * this.LAB_TOKENS.out) / 1e6 * 100;
const cost = !known ? "" : cents < 1 ? "under 1¢" : `about ${Math.ceil(cents)}¢`;
return {...r, pin, pout, known, cost,
label: `${r.star ? "⭐ " : ""}${r.name} (${r.maker})${cost ? ` · ${cost} for the whole lab` : ""}`};
},
fillModelSelect(select) {
const opts = list => list.map(m => `<option value="${this.esc(m.id)}">${this.esc(m.label)}</option>`).join("");
let pick;
if (this.showPaid && this.paid.length) {
const groups = [["⭐ Recommended: start here", this.paid.filter(m => m.star)], ["Also good", this.paid.filter(m => !m.star)]];
select.innerHTML = groups.filter(g => g[1].length)
.map(([label, list]) => `<optgroup label="${this.esc(label)}">${opts(list)}</optgroup>`).join("");
pick = this.paid[0].id;
} else {
select.innerHTML = opts(this.free);
pick = this.FREE_PREFERRED.find(p => this.free.some(m => m.id === p)) || (this.free[0] && this.free[0].id) || "";
}
select.value = pick;
this.model = pick;
},
/* The note under the model list while paid models show: who makes the chosen one, why it is on the list, its cost. */
paidNote() {
if (!this.showPaid) return "";
const m = this.paid.find(p => p.id === this.model);
const about = !m ? "" : `<br><b>${this.esc(m.name)}</b> (${this.esc(m.maker)}) · ${this.esc(m.why)}` +
(m.known ? ` · $${m.pin.toFixed(2)} in / $${m.pout.toFixed(2)} out per million tokens · this whole lab: <b>${m.cost}</b>` : "");
return `<div class="keystat meh">💳 <b>Paid models charge your OpenRouter credit.</b> Untick the box to go back to free models.${about}</div>`;
},
async checkKey(key) {
key = (key || "").trim();
if (!key) return "";
if (!key.startsWith("sk-or-") || key.length < 30) {
return '<div class="keystat bad">❌ That does not look like an OpenRouter key. OpenRouter keys start with <b>sk-or-</b>. Copy the whole key from openrouter.ai, under Keys.</div>';
}
let r;
try {
r = await fetch(OR + "/key", {headers: {"Authorization": "Bearer " + key}});
} catch (e) {
return '<div class="keystat meh">⚠️ Could not reach OpenRouter to check the key. Try a run anyway.</div>';
}
if (r.status === 401 || r.status === 403) {
return '<div class="keystat bad">❌ OpenRouter did not accept this key. It may be mistyped, deleted, or expired. Make a new one at openrouter.ai, under Keys.</div>';
}
if (!r.ok) return `<div class="keystat meh">⚠️ Could not check the key right now (OpenRouter said ${r.status}). Try a run anyway.</div>`;
const d = ((await r.json()) || {}).data || {};
const parts = ["✅ <b>Key works.</b>"];
if (typeof d.limit === "number" && d.limit) {
const left = typeof d.limit_remaining === "number" ? d.limit_remaining : d.limit - (d.usage || 0);
parts.push(`Credit left: <b>$${left.toFixed(2)}</b> of $${d.limit.toFixed(2)}.`);
} else if (typeof d.usage === "number") {
parts.push(`Used so far: $${d.usage.toFixed(2)} (no spending limit set on this key).`);
}
if (d.is_free_tier) parts.push("This is a <b>free-tier</b> key: free models only, about 20 requests a minute.");
return `<div class="keystat ok">${parts.join(" ")}</div>`;
},
backend() {
if (this.key) return {url: OR + "/chat/completions", key: this.key, model: this.model, where: "OpenRouter"};
if (this.isLocal) return {url: "http://localhost:11434/v1/chat/completions", key: "ollama", model: this.LOCAL_MODEL, where: "local Ollama"};
return null;
},
friendly(status, data) {
const m = (data && data.error && (data.error.message || data.error)) || "";
if (status === 429) return "This free model is busy, or you hit the free limit (about 20 requests a minute). Wait a minute, or pick another model at the top.";
if (status === 401 || status === 403) return "The key was not accepted. Check that you pasted the whole OpenRouter key.";
if (status === 402) return "This key has no credit for that model. Pick a free model (its name ends in :free).";
if (status === 404) return "That model is not available right now. Pick another one at the top.";
return `The model service returned an error (${status}): ${String(m).slice(0, 200)}`;
},
/* One model call, retried once on a free-tier rate limit. Returns {message, usage, secs, model, where}. */
async chat(messages, tools) {
const b = this.backend();
if (!b) { const e = new Error("Paste your OpenRouter key in block 0 at the top first."); e.friendly = true; throw e; }
const body = {model: b.model, messages, temperature: 0, max_tokens: 1500};
if (tools && tools.length) body.tools = tools;
for (let attempt = 0; attempt < 2; attempt++) {
const t0 = performance.now();
let r;
try {
// Local Ollama only accepts plain requests from a web page; OpenRouter also takes the key and a title.
const headers = b.where === "local Ollama" ? {"Content-Type": "application/json"}
: {"Authorization": "Bearer " + b.key, "Content-Type": "application/json", "X-Title": "MIS 752 Lite labs"};
r = await fetch(b.url, {method: "POST", body: JSON.stringify(body), headers});
} catch (err) {
const e = new Error(b.where === "local Ollama" ? "The local model is not running. Start Ollama, or paste an OpenRouter key."
: "Could not reach OpenRouter. Check your internet connection.");
e.friendly = true; throw e;
}
const secs = (performance.now() - t0) / 1000;
let data = {};
try { data = await r.json(); } catch (e) { /* empty body */ }
if (r.status === 429 && attempt === 0) { await this.sleep(4000); continue; }
if (!r.ok || data.error) { const e = new Error(this.friendly(r.status, data)); e.friendly = true; throw e; }
const message = (data.choices && data.choices[0] && data.choices[0].message) || {};
return {message, usage: data.usage || {}, secs, model: b.model, where: b.where};
}
},
/* ---------- drawing ---------- */
VERDICT: {
right: ["✅", "RIGHT", "grounded in the facts", "ok"],
honest: ["🤷", "COULDN'T ANSWER", "honest, but no help", "meh"],
wrong: ["❌", "MADE IT UP", "sounds sure, not checked", "bad"],
skipped: ["⚠️", "SKIPPED A STEP", "a lookup it needed never happened", "bad"],
miscopied: ["✏️", "COPIED IT WRONG", "the tool was right, the answer is not", "bad"],
open: ["💬", "ANSWERED", "your question has no answer key", "meh"],
error: ["⛔", "COULD NOT RUN", "", "bad"],
used: ["✅", "USED YOUR TOOL", "it read your paragraph and called it", "ok"],
unused: ["⚠️", "IGNORED YOUR TOOL", "rewrite the 'what it is for' paragraph and try again", "meh"],
},
step(icon, who, text, cls, fresh = false) {
return `<div class="tl-row ${cls}${fresh ? " new" : ""}"><span class="tl-ic">${icon}</span><span class="tl-who">${who}</span>` +
`<span class="tl-tx">${this.esc(text)}</span></div>`;
},
pill(code) {
if (!code) return "";
const [emo, label, mean, css] = this.VERDICT[code];
return `<div class="vpill ${css}"><span class="v-emo">${emo}</span><span><span class="v-label">${label}</span>` +
`<span class="v-mean">${mean}</span></span></div>`;
},
/* One result panel: headline first (verdict), then the answer, then how it got there, then the raw output drawer. */
panel({cls, title, sub, verdict, answer, busy, rows, term}) {
const out = [`<div class="p-top"><div><div class="p-title">${title}</div><div class="p-sub">${sub}</div></div>${this.pill(verdict)}</div>`];
if (answer != null) out.push(`<div class="answer"><div class="a-label">💬 Its answer</div>${this.esc(answer)}</div>`);
else if (busy) out.push('<div class="answer waiting"><div class="a-label">💬 Its answer</div>⏳ working…</div>');
if (rows && rows.length) out.push(`<div class="tl"><div class="tl-head">How it got there</div>${rows.join("")}</div>`);
out.push(term || "");
return `<div class="panel ${cls}">${out.join("")}</div>`;
},
terminal(system, question, events, offered) {
const L = [`$ model: ${events.model || "?"}`, `$ tools offered: ${offered}`, "", `→ SYSTEM ${system}`, `→ USER ${question}`];
for (const e of events) {
if (e[0] === "raw") {
const r = e[1];
let meta = `${r.secs.toFixed(1)} s`;
if (r.tokens_in != null) meta += ` · ${r.tokens_in} tokens in, ${r.tokens_out} out`;
L.push("", `── call #${r.turn + 1} to the model (${meta}) ──`);
for (const c of r.tool_calls) L.push(`← MODEL asks for a tool: ${c.name}(${c.arguments})`);
if ((r.content || "").trim() || !r.tool_calls.length) L.push(`← MODEL says: ${(r.content || "").trim() || "(nothing)"}`);
} else if (e[0] === "result") {
L.push(`⚙ YOUR SYSTEM ran ${e[1]} and returned:`, JSON.stringify(e[2], null, 2), "→ that result goes back to the model");
} else if (e[0] === "error") {
L.push(`!! ERROR: ${e[1]}`);
}
}
return `<details class="out"><summary>Output: what the computer actually sent and received</summary><pre class="term">${this.esc(L.join("\n"))}</pre></details>`;
},
note(text, big = false) { return `<div class="note${big ? " big" : ""}">${this.esc(text)}</div>`; },
/* ---------- thinking aids shared by every lab: book links and model answers ---------- */
BOOK: "https://deepneuro.ai/books/ai-healthcare/Healthcare-Analytics-and-AI.pdf",
/* A "Read more in the book" link to one section: pdf is the PDF page (for #page=), page is the printed page number. */
bookLink(title, pdf, page) {
return `<a class="bookref" href="${this.BOOK}#page=${pdf}" target="_blank" rel="noopener">📖 Read more in the book: ${this.esc(title)} (p. ${page})</a>`;
},
words(t) { return (String(t || "").trim().match(/\S+/g) || []).length; },
sentences(t) { return (String(t || "").match(/[^.!?]{12,}[.!?](\s|$)/g) || []).length; },
/* "Compare with a model answer": the button unlocks only after the student has written two sentences of their own.
* onReveal gets what they had written at that moment, so the hand-in can show their thinking came first. */
modelAnswer(textarea, holder, answer, onReveal) {
holder.innerHTML = '<button type="button" class="ma-btn" disabled>💡 Compare with a model answer</button>' +
'<span class="ma-hint">Unlocks after you write two sentences of your own.</span>';
const btn = holder.querySelector(".ma-btn"), hint = holder.querySelector(".ma-hint");
const sync = () => { const ok = this.sentences(textarea.value) >= 2 || this.words(textarea.value) >= 30; btn.disabled = !ok; hint.hidden = ok; };
textarea.addEventListener("input", sync);
sync();
btn.addEventListener("click", () => {
if (onReveal) onReveal(textarea.value.trim());
holder.innerHTML = `<div class="ma-box"><div class="ma-label">💡 One good answer (yours can differ and still be right)</div>${this.esc(answer)}</div>`;
});
},
/* Three big prediction buttons. Returns a function that reads the current choice. Each lab passes its own lead-in. */
predict(container, choices, lead = "Without tools, the model will…") {
let chosen = "";
container.innerHTML = `<div class="predlabel">🤔 What do you think will happen? <span>${this.esc(lead)}</span></div>` +
`<div class="btnrow">${choices.map(([emo, txt]) => `<button type="button" class="predbtn" data-v="${this.esc(txt)}">${emo} ${this.esc(txt)}</button>`).join("")}</div>`;
container.querySelectorAll("button.predbtn").forEach(btn => btn.addEventListener("click", () => {
chosen = btn.dataset.v;
container.querySelectorAll("button.predbtn").forEach(b => b.classList.toggle("selected", b === btn));
}));
return () => chosen;
},
download(filename, text) {
const url = URL.createObjectURL(new Blob([text], {type: "text/plain;charset=utf-8"}));
const a = document.createElement("a");
a.href = url; a.download = filename; document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 1000);
},
today() {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
},
/* Wire block 0: key box, model list, paid checkbox. */
async wireConnect() {
const key = this.$("#key"), sel = this.$("#model"), paid = this.$("#paid"), stat = this.$("#keystat"), pnote = this.$("#paidnote");
if (this.isLocal) this.$("#localnote").hidden = false;
sel.innerHTML = '<option>loading models…</option>';
await this.loadModels();
this.showPaid = paid.checked; // a browser can restore the tick on reload
this.fillModelSelect(sel);
pnote.innerHTML = this.paidNote();
sel.addEventListener("change", () => { this.model = sel.value; pnote.innerHTML = this.paidNote(); });
paid.addEventListener("change", () => {
this.showPaid = paid.checked;
this.fillModelSelect(sel);
pnote.innerHTML = this.paidNote();
});
let lastChecked = "";
const check = async () => {
this.key = key.value.trim();
if (this.key === lastChecked) return;
lastChecked = this.key;
stat.innerHTML = this.key ? '<div class="status">checking the key…</div>' : "";
stat.innerHTML = await this.checkKey(this.key);
};
key.addEventListener("input", () => { this.key = key.value.trim(); if (this.key.startsWith("sk-or-") && this.key.length > 40) check(); });
key.addEventListener("change", check);
key.addEventListener("blur", check);
},
};