File size: 18,685 Bytes
fc75aa2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9d798d
fc75aa2
 
f9d798d
 
 
fc75aa2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f9d798d
fc75aa2
f9d798d
 
 
 
 
 
 
 
 
fc75aa2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
/* 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) {
      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.status === 403) {
      return '<div class="keystat meh">⚠️ OpenRouter refused to check this key (403). That usually means a spending limit or budget was reached, not a bad key. Try a free model (its name ends in :free).</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 = String((data && data.error && (data.error.message || data.error)) || "").replace(/\s+/g, " ").trim();
    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) return "The key was not accepted. Check that you pasted the whole OpenRouter key.";
    if (status === 402) return "This key has no credit left for that model. Add credit at openrouter.ai, or pick a free model (its name ends in :free).";
    if (status === 403) {
      if (/budget|limit|spend|credit/i.test(m)) return `Your key is fine, but it has reached a spending limit, so OpenRouter refused this paid model. Pick a free model (its name ends in :free), or raise the limit at openrouter.ai under Settings. OpenRouter said: ${m.slice(0, 160)}`;
      if (/moderat|flag/i.test(m)) return `OpenRouter's safety filter blocked this request. OpenRouter said: ${m.slice(0, 160)}`;
      return `OpenRouter refused this request (403). Your key may still be fine: try a free model (its name ends in :free). OpenRouter said: ${m.slice(0, 160)}`;
    }
    if (status === 404) return /tool/i.test(m) ? "That model cannot use tools right now. Pick another one at the top." : "That model is not available right now. Pick another one at the top.";
    return `The model service returned an error (${status}): ${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);
  },
};