/* 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]) => `${opts(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}
`; }, async checkKey(key) { key = (key || "").trim(); if (!key) return ""; if (!key.startsWith("sk-or-") || key.length < 30) { return '
❌ That does not look like an OpenRouter key. OpenRouter keys start with sk-or-. Copy the whole key from openrouter.ai, under Keys.
'; } let r; try { r = await fetch(OR + "/key", {headers: {"Authorization": "Bearer " + key}}); } catch (e) { return '
⚠️ Could not reach OpenRouter to check the key. Try a run anyway.
'; } if (r.status === 401 || r.status === 403) { return '
❌ 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 `
${icon}${who}` + `${this.esc(text)}
`; }, pill(code) { if (!code) return ""; const [emo, label, mean, css] = this.VERDICT[code]; return `
${emo}${label}` + `${mean}
`; }, /* 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)}
` + `
${choices.map(([emo, txt]) => ``).join("")}
`; 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 = ''; 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 ? '
checking the key…
' : ""; 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); }, };