/* 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 => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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 => ``).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]) => ``).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 ? "" : ` ${this.esc(m.name)} (${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: ${m.cost}` : "");
return `
💳 Paid models charge your OpenRouter credit. Untick the box to go back to free models.${about}
❌ OpenRouter did not accept this key. It may be mistyped, deleted, or expired. Make a new one at openrouter.ai, under Keys.
';
}
if (!r.ok) return `
⚠️ Could not check the key right now (OpenRouter said ${r.status}). Try a run anyway.
`;
const d = ((await r.json()) || {}).data || {};
const parts = ["✅ Key works."];
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: $${left.toFixed(2)} 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 free-tier key: free models only, about 20 requests a minute.");
return `
${parts.join(" ")}
`;
},
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 `
`;
},
/* 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 = [`
${title}
${sub}
${this.pill(verdict)}
`];
if (answer != null) out.push(`
💬 Its answer
${this.esc(answer)}
`);
else if (busy) out.push('
💬 Its answer
⏳ working…
');
if (rows && rows.length) out.push(`
How it got there
${rows.join("")}
`);
out.push(term || "");
return `
${out.join("")}
`;
},
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 `Output: what the computer actually sent and received
${this.esc(L.join("\n"))}
`;
},
note(text, big = false) { return `
${this.esc(text)}
`; },
/* ---------- 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 `📖 Read more in the book: ${this.esc(title)} (p. ${page})`;
},
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 = '' +
'Unlocks after you write two sentences of your own.';
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 = `
💡 One good answer (yours can differ and still be right)
${this.esc(answer)}
`;
});
},
/* 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 = `
🤔 What do you think will happen? ${this.esc(lead)}