diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml
index 39f3fea..97eb277 100644
--- a/.github/workflows/deploy-pages.yml
+++ b/.github/workflows/deploy-pages.yml
@@ -127,7 +127,7 @@ jobs:
run: |
cd webui && npm ci && npm run build
cd ..
- npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts
+ npx playwright test tests/e2e/pages-chat.spec.ts tests/e2e/failover-dual-endpoint.spec.ts tests/e2e/model-picker.spec.ts tests/e2e/message-actions.spec.ts tests/e2e/health-panel.spec.ts tests/e2e/turnstile-gate.spec.ts tests/e2e/catalog-explorer.spec.ts tests/e2e/export-session.spec.ts tests/e2e/hash-routing.spec.ts tests/e2e/import-session.spec.ts tests/e2e/shortcuts-sheet.spec.ts tests/e2e/streaming-polish.spec.ts tests/e2e/compare-mode.spec.ts tests/e2e/vision-attach.spec.ts tests/e2e/tier-settings.spec.ts
- name: Run live Pages chat e2e (real proxy)
env:
diff --git a/AGENTS.md b/AGENTS.md
index d634d87..e561f87 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -189,6 +189,7 @@ When reviewing or submitting changes:
14. **Bootstrap merge:** `FailoverProvider` must use `loadRuntimeConfig()` (merged `chat_proxy.json` endpoints), not raw `readRuntimeConfig()` alone — stale single-endpoint localStorage hides secondaries until cleared (see `docs/CAVEATS.md`)
15. **Cloudflare auth 10000:** regenerate CF API token with Workers Scripts Edit; when deploy skips, `WORKER_URL` secret keeps Pages building (see `docs/solutions/workflow-issues/github-pages-webui-deploy-and-secrets.md`)
16. **Render deploy-mode YAML:** omit `DATABASE_URL` env var entirely — do not set empty string; deploy generator excludes `database_url` and `allowed_routes`
+17. murm-ui streaming flicker: keep `webui/patches/murm-ui+0.2.0.patch` (plain-text tail until complete) via `postinstall` patch-package — bump alone was insufficient (see `docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md`)
## Compound Engineering
diff --git a/CONCEPTS.md b/CONCEPTS.md
index 1ec6a22..58d9018 100644
--- a/CONCEPTS.md
+++ b/CONCEPTS.md
@@ -41,6 +41,10 @@ Shared vocabulary for the static chat gateway and Python library.
| **Session export** | Sidebar menu download of current chat as Markdown or JSON (client-only; no server copy) |
| **Hash session link** | URL `#/chat/{sessionId}` restores a session from local IndexedDB on the same browser/profile |
| **Conversation import** | File-picker restore of exported Markdown/JSON into a new local session (symmetry with session export) |
+| **Provider tier** | User-ordered stage in the omnifail stack (quality API, headless web UI, SearXNG discovery, proxy failover) |
+| **Compare mode** | Two-column chat UI: same prompt to two independently configured sources |
+| **Web UI provider tier** | Optional headless-browser route against free web chat UIs; requires user-run runner; off by default on public demo |
+| **SearXNG discovery tier** | Metasearch pass to suggest candidate free web chat URLs when API/web tiers exhaust |
## Learnings index
diff --git a/STRATEGY.md b/STRATEGY.md
index d3716e6..824110c 100644
--- a/STRATEGY.md
+++ b/STRATEGY.md
@@ -1,6 +1,6 @@
---
name: llm-fallbacks
-last_updated: 2026-07-24
+last_updated: 2026-07-25
---
# Strategy
@@ -15,7 +15,7 @@ Developers want free LLM chat without manually tracking which models exist, whic
Endpoint bootstrap is three-layer: Pages CI writes `docs/config.js`, the browser fetches `chat_proxy.json` at runtime, and the committed artifact lists dual proxy URLs for failover.
-High availability on free tiers means **best-effort failover with cold-start penalties**, not paid uptime. We say that plainly.
+High availability on free tiers means **best-effort failover with cold-start penalties**, not paid uptime. Exhaust free, legal routes the user opted into before giving up; cold starts, quotas, and ToS limits still apply. We say that plainly.
## Who it's for
@@ -33,13 +33,13 @@ Open-source builders and power users who want a demo-quality free LLM gateway ti
### Static public chat (GitHub Pages)
-Minimal chat SPA as the repo homepage. Uses `free_models.json` for the model browser. Calls proxies only.
+Static chat SPA as the repo homepage. Uses `free_models.json` for the model browser. Zero-config path uses ranked catalog + edge/container proxies only. Optional BYOK and user-run companions never embed repo-owned keys. Demo job: make ranked free failover tangible — not become Open WebUI.
_Keeps secrets off the static surface and makes the project tangible to visitors._
### Edge + container proxy HA
-Cloudflare Worker primary (CORS, guest auth, rate limits, short fallback chain) plus **Render LiteLLM** as v1 secondary. Both driven by generated configs. Secondary redeploy via Render API when deploy hooks are unavailable; `chat_proxy.json` preserves dual endpoints across Worker-only CI runs.
+Cloudflare Worker primary (CORS, guest auth, rate limits, short fallback chain) plus **Render LiteLLM** as v1 secondary. Both driven by generated configs. Secondary redeploy via Render API when deploy hooks are unavailable; `chat_proxy.json` preserves dual endpoints across Worker-only CI runs. Public $0 HA remains Worker + Render LiteLLM; user-run runners are opt-in power-user extensions, not part of the dual-proxy HA story.
_Runtime routing and keys cannot live in the browser; reuses `litellm_config_free.yaml` and `free` alias work._
@@ -61,6 +61,7 @@ _Without a living remediation track, operator runbooks drift from production._
- Full TypeScript port of llm-fallbacks discovery
- True multi-region DNS HA on $0
- Browser-direct provider calls with repo-owned keys on the public homepage
+- Agent gateway features (MCP marketplace, tool execution loops, cloud session sync) — demo stays display/routing-first
## Messaging
diff --git a/docs/CAVEATS.md b/docs/CAVEATS.md
index 6883c43..d8d7bcc 100644
--- a/docs/CAVEATS.md
+++ b/docs/CAVEATS.md
@@ -16,6 +16,10 @@ Honest limits for the public chat demo and the Python library.
| **Routing headers** | The routing chip reads `x-llm-fallbacks-endpoint` and LiteLLM headers from proxy responses. After edge changes, redeploy the Worker (`Deploy Proxies` workflow) for production to expose them. |
| **Turnstile** | Optional bot check when `TURNSTILE_SECRET` is set on the Worker and `turnstileSiteKey` is in `docs/config.js`. Skipped in local dev when secrets are absent. |
| **Hash session links** | `#/chat/{id}` only works when that session exists in your browser’s IndexedDB. Copy the link on another device or after clearing site data and you get a new empty chat — use **Export as Markdown/JSON** to share transcripts. |
+| **Provider tiers vs free-tier limits** | The **Tiers** panel is the omnifail *route stack* (direct/BYOK → optional local runner → SearXNG → cloud proxy). That is separate from cloud *free-tier* rate limits and quotas on OpenRouter / Workers. Reordering tiers changes which route we try first; it does not raise provider quotas. |
+| **Bootstrap merge** | Chat endpoints come from `loadRuntimeConfig()` (page `config.js` + `chat_proxy.json` merged with localStorage). If you once saved a single endpoint, that stale localStorage value can hide newer secondaries until you clear site data or re-save Server settings — see AGENTS.md pitfall 14. |
+| **Web-UI runner & SearXNG** | Both are **opt-in** and empty by default on the public homepage. You run them locally; you own target-site and SearXNG terms of service. We do not harvest credentials. Exhausting enabled tiers still fails honestly — this is best-effort free HA, not “never fail.” |
+| **Vision export** | Session Markdown/JSON export is **text-only**. Attached images are not serialized into export files (thumbnails stay in IndexedDB until you clear site data). |
## Library and CI
diff --git a/docs/assets/chat.css b/docs/assets/chat.css
index 089f907..17d5187 100644
--- a/docs/assets/chat.css
+++ b/docs/assets/chat.css
@@ -1269,4 +1269,112 @@
height: 16px;
color: inherit;
}
+
+/* node_modules/murm-ui/dist/plugins/attachment/attachment.css */
+.mur-attachment-previews {
+ display: flex;
+ gap: 8px;
+ padding: 4px 8px 12px 8px;
+ overflow-x: auto;
+ width: 100%;
+ max-width: 768px;
+ pointer-events: auto;
+}
+.mur-attachment-previews[hidden] {
+ display: none;
+}
+.mur-attachment-preview-item {
+ position: relative;
+ display: inline-block;
+ flex-shrink: 0;
+}
+.mur-attachment-preview-item.mur-attachment-processing {
+ opacity: 0.68;
+}
+.mur-attachment-preview-item img {
+ height: 48px;
+ border-radius: 6px;
+ object-fit: cover;
+}
+.mur-file-preview {
+ height: 48px;
+ padding: 0 12px;
+ background: var(--mur-surface);
+ border-radius: 6px;
+ display: flex;
+ align-items: center;
+ font-size: 0.85rem;
+ color: var(--mur-text-muted);
+ border: 1px solid var(--mur-border);
+}
+.mur-attachment-preview-item.mur-attachment-error .mur-file-preview {
+ color: var(--mur-danger-text);
+ border-color: var(--mur-danger-border);
+ background: var(--mur-danger-bg);
+}
+.mur-attachment-spinner {
+ width: 14px;
+ height: 14px;
+ border: 2px solid var(--mur-border);
+ border-top-color: var(--mur-text-muted);
+ border-radius: 50%;
+ animation: mur-attachment-spin 0.8s linear infinite;
+ margin-right: 8px;
+ flex-shrink: 0;
+}
+.mur-attachment-drag-active .mur-chat-form {
+ border-color: var(--mur-primary);
+ box-shadow: 0 0 0 3px var(--mur-attachment-drag-ring);
+}
+@keyframes mur-attachment-spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
+.mur-attachment-remove-btn {
+ position: absolute;
+ top: -6px;
+ right: -6px;
+ background: var(--mur-text-secondary);
+ color: var(--mur-inverse-text);
+ border: none;
+ border-radius: 50%;
+ width: 20px;
+ height: 20px;
+ font-size: 14px;
+ line-height: 1;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ box-shadow: var(--mur-shadow-attachment);
+ opacity: 0.8;
+}
+.mur-attachment-remove-btn:hover {
+ background: var(--mur-danger);
+ opacity: 1;
+}
+.mur-message-attachments {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-bottom: 0.5rem;
+}
+.mur-attachment-image {
+ max-width: 100%;
+ max-height: 300px;
+ border-radius: 0.5rem;
+ object-fit: contain;
+ background-color: var(--mur-surface);
+}
+.mur-attachment-file-pill {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.5rem 0.75rem;
+ background: var(--mur-surface);
+ border-radius: 0.5rem;
+ font-size: 0.85rem;
+ color: var(--mur-text-muted);
+ border: 1px solid var(--mur-border);
+}
/*# sourceMappingURL=chat.css.map */
diff --git a/docs/assets/chat.js b/docs/assets/chat.js
index dbc11c3..1355df1 100644
--- a/docs/assets/chat.js
+++ b/docs/assets/chat.js
@@ -1382,6 +1382,7 @@ function syncDOMNode(target, source) {
var ICON_COPY = ` `;
var ICON_CHECK = ` `;
var ICON_EDIT = ` `;
+var ICON_PAPERCLIP = ` `;
var ICON_CHEVRON = ` `;
var ICON_MORE_VERTICAL = ` `;
var ICON_PIN = ` `;
@@ -5012,12 +5013,319 @@ function CopyPlugin() {
};
}
+// node_modules/murm-ui/dist/plugins/attachment/attachment-plugin.js
+var DEFAULT_ACCEPTED_TYPES = "image/*,text/*,.csv,.json,.md";
+var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set(["csv", "json", "md"]);
+function AttachmentPlugin(config) {
+ var _a, _b;
+ const maxSize = (_a = config === null || config === void 0 ? void 0 : config.maxFileSize) !== null && _a !== void 0 ? _a : 20 * 1024 * 1024;
+ const acceptedTypes = (_b = config === null || config === void 0 ? void 0 : config.acceptedTypes) !== null && _b !== void 0 ? _b : DEFAULT_ACCEPTED_TYPES;
+ let queue = [];
+ let fileInput;
+ let previewContainer;
+ let attachBtn;
+ let inputContext = null;
+ let dragDepth = 0;
+ let destroyed = false;
+ const syncSubmitState = () => inputContext === null || inputContext === void 0 ? void 0 : inputContext.requestSubmitStateSync();
+ const renderPreviews = () => {
+ if (!previewContainer)
+ return;
+ previewContainer.innerHTML = "";
+ previewContainer.hidden = queue.length === 0;
+ queue.forEach((item) => {
+ var _a2, _b2;
+ const previewItem = el("div", `mur-attachment-preview-item mur-attachment-${item.state}`);
+ previewItem.setAttribute("data-attachment-state", item.state);
+ if (item.state === "processing") {
+ previewItem.appendChild(el("div", "mur-file-preview", null, [
+ el("span", "mur-attachment-spinner"),
+ el("span", "", { textContent: (_a2 = item.statusText) !== null && _a2 !== void 0 ? _a2 : "Processing..." })
+ ]));
+ } else if (item.state === "error") {
+ previewItem.appendChild(el("div", "mur-file-preview", { textContent: (_b2 = item.error) !== null && _b2 !== void 0 ? _b2 : "Unsupported type" }));
+ } else {
+ renderReadyPreview(item, previewItem);
+ }
+ const removeBtn = el("button", "mur-attachment-remove-btn", {
+ innerHTML: "\xD7",
+ type: "button",
+ onclick: () => {
+ queue = queue.filter((queuedItem) => queuedItem.id !== item.id);
+ renderPreviews();
+ syncSubmitState();
+ }
+ });
+ removeBtn.setAttribute("aria-label", `Remove ${item.fileName}`);
+ previewItem.appendChild(removeBtn);
+ previewContainer.appendChild(previewItem);
+ });
+ };
+ const queueFiles = (files) => {
+ for (const file of files) {
+ void queueFile(file);
+ }
+ };
+ const queueFile = async (file) => {
+ var _a2, _b2;
+ const item = {
+ id: uuidv7(),
+ fileName: file.name || "Untitled file",
+ mimeType: file.type || "application/octet-stream",
+ state: "processing",
+ statusText: (config === null || config === void 0 ? void 0 : config.uploadFile) ? "Uploading..." : "Processing..."
+ };
+ queue.push(item);
+ renderPreviews();
+ syncSubmitState();
+ if (file.size > maxSize) {
+ updateItemError(item.id, "File too large");
+ (_a2 = config === null || config === void 0 ? void 0 : config.onSizeExceeded) === null || _a2 === void 0 ? void 0 : _a2.call(config, file, maxSize);
+ return;
+ }
+ try {
+ const block = await processFile(file);
+ updateItemReady(item.id, block);
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Unsupported type";
+ updateItemError(item.id, message);
+ if (message === "Unsupported type") {
+ (_b2 = config === null || config === void 0 ? void 0 : config.onUnsupportedFile) === null || _b2 === void 0 ? void 0 : _b2.call(config, file);
+ }
+ }
+ };
+ const updateItemReady = (id, block) => {
+ const item = queue.find((queuedItem) => queuedItem.id === id);
+ if (!item || destroyed)
+ return;
+ item.state = "ready";
+ item.block = block;
+ item.mimeType = getBlockMimeType(block, item.mimeType);
+ item.statusText = void 0;
+ item.error = void 0;
+ renderPreviews();
+ syncSubmitState();
+ };
+ const updateItemError = (id, error) => {
+ const item = queue.find((queuedItem) => queuedItem.id === id);
+ if (!item || destroyed)
+ return;
+ item.state = "error";
+ item.error = error;
+ item.statusText = void 0;
+ renderPreviews();
+ syncSubmitState();
+ };
+ const processFile = async (file) => {
+ var _a2, _b2;
+ const handler = (_a2 = config === null || config === void 0 ? void 0 : config.fileHandlers) === null || _a2 === void 0 ? void 0 : _a2.find((candidate) => candidate.accepts(file));
+ if (handler) {
+ return handler.process(file);
+ }
+ if (config === null || config === void 0 ? void 0 : config.uploadFile) {
+ const uploaded = await config.uploadFile(file);
+ return {
+ id: uuidv7(),
+ type: "file",
+ mimeType: uploaded.type,
+ name: (_b2 = uploaded.name) !== null && _b2 !== void 0 ? _b2 : file.name,
+ data: uploaded.data
+ };
+ }
+ if (file.type.startsWith("image/")) {
+ return {
+ id: uuidv7(),
+ type: "file",
+ mimeType: file.type,
+ name: file.name,
+ data: await readFile(file, "data-url")
+ };
+ }
+ if (isTextLikeFile(file)) {
+ return {
+ id: uuidv7(),
+ type: "file",
+ mimeType: file.type || mimeTypeFromName(file.name),
+ name: file.name,
+ data: await readFile(file, "text")
+ };
+ }
+ throw new Error("Unsupported type");
+ };
+ const onFileInputChange = () => {
+ queueFiles(Array.from(fileInput.files || []));
+ fileInput.value = "";
+ };
+ const onDragEnter = (event) => {
+ if (!hasDraggedFiles(event))
+ return;
+ event.preventDefault();
+ dragDepth++;
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.add("mur-attachment-drag-active");
+ };
+ const onDragOver = (event) => {
+ if (!hasDraggedFiles(event))
+ return;
+ event.preventDefault();
+ };
+ const onDragLeave = (event) => {
+ if (!hasDraggedFiles(event))
+ return;
+ event.preventDefault();
+ dragDepth = Math.max(0, dragDepth - 1);
+ if (dragDepth === 0) {
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.remove("mur-attachment-drag-active");
+ }
+ };
+ const onDrop = (event) => {
+ var _a2;
+ if (!hasDraggedFiles(event))
+ return;
+ event.preventDefault();
+ dragDepth = 0;
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.remove("mur-attachment-drag-active");
+ queueFiles(Array.from(((_a2 = event.dataTransfer) === null || _a2 === void 0 ? void 0 : _a2.files) || []));
+ };
+ const onPaste = (event) => {
+ var _a2;
+ const files = Array.from(((_a2 = event.clipboardData) === null || _a2 === void 0 ? void 0 : _a2.files) || []);
+ if (files.length === 0)
+ return;
+ if (!hasClipboardText(event)) {
+ event.preventDefault();
+ }
+ queueFiles(files);
+ };
+ return {
+ name: "attachments",
+ onInputMount: (ctx) => {
+ inputContext = ctx;
+ destroyed = false;
+ previewContainer = el("div", "mur-attachment-previews");
+ previewContainer.hidden = true;
+ fileInput = el("input", "", { type: "file", hidden: true, multiple: true, accept: acceptedTypes });
+ attachBtn = el("button", "mur-form-icon-btn", {
+ type: "button",
+ innerHTML: ICON_PAPERCLIP,
+ onclick: () => fileInput.click()
+ });
+ attachBtn.setAttribute("aria-label", "Attach files");
+ attachBtn.title = "Attach files";
+ ctx.form.prepend(attachBtn);
+ if (config === null || config === void 0 ? void 0 : config.previewMountSelector) {
+ const selectorRoot = config.previewMountSelectorScope === "document" ? document : ctx.container;
+ const customTarget = selectorRoot.querySelector(config.previewMountSelector);
+ if (customTarget) {
+ customTarget.appendChild(previewContainer);
+ } else {
+ console.error(`AttachmentPlugin: Could not find element matching previewMountSelector "${config.previewMountSelector}". Image previews will not be visible.`);
+ }
+ } else {
+ ctx.form.before(previewContainer);
+ }
+ ctx.form.appendChild(fileInput);
+ fileInput.addEventListener("change", onFileInputChange);
+ ctx.container.addEventListener("dragenter", onDragEnter);
+ ctx.container.addEventListener("dragover", onDragOver);
+ ctx.container.addEventListener("dragleave", onDragLeave);
+ ctx.container.addEventListener("drop", onDrop);
+ ctx.input.addEventListener("paste", onPaste);
+ },
+ hasPendingData: () => queue.some((item) => item.state === "ready" && item.block),
+ isSubmitBlocked: () => queue.some((item) => item.state === "processing"),
+ onUserSubmit: (msg) => {
+ const readyBlocks = queue.flatMap((item) => item.state === "ready" && item.block ? [item.block] : []);
+ if (readyBlocks.length > 0) {
+ msg.blocks.unshift(...readyBlocks);
+ queue = queue.filter((item) => item.state !== "ready");
+ renderPreviews();
+ syncSubmitState();
+ }
+ },
+ destroy: () => {
+ destroyed = true;
+ fileInput === null || fileInput === void 0 ? void 0 : fileInput.removeEventListener("change", onFileInputChange);
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("dragenter", onDragEnter);
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("dragover", onDragOver);
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("dragleave", onDragLeave);
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.removeEventListener("drop", onDrop);
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.input.removeEventListener("paste", onPaste);
+ inputContext === null || inputContext === void 0 ? void 0 : inputContext.container.classList.remove("mur-attachment-drag-active");
+ fileInput === null || fileInput === void 0 ? void 0 : fileInput.remove();
+ attachBtn === null || attachBtn === void 0 ? void 0 : attachBtn.remove();
+ previewContainer === null || previewContainer === void 0 ? void 0 : previewContainer.remove();
+ queue = [];
+ inputContext = null;
+ dragDepth = 0;
+ }
+ };
+}
+function renderReadyPreview(item, previewItem) {
+ var _a, _b;
+ const block = item.block;
+ if ((block === null || block === void 0 ? void 0 : block.type) === "file" && block.mimeType.startsWith("image/")) {
+ previewItem.appendChild(el("img", "", { src: block.data, alt: (_a = block.name) !== null && _a !== void 0 ? _a : item.fileName }));
+ return;
+ }
+ const label = (block === null || block === void 0 ? void 0 : block.type) === "file" ? (_b = block.name) !== null && _b !== void 0 ? _b : item.fileName : item.fileName;
+ previewItem.appendChild(el("div", "mur-file-preview", { textContent: `\u{1F4C4} ${label}` }));
+}
+function getBlockMimeType(block, fallback) {
+ return block.type === "file" ? block.mimeType : fallback;
+}
+function hasDraggedFiles(event) {
+ var _a;
+ const types = (_a = event.dataTransfer) === null || _a === void 0 ? void 0 : _a.types;
+ if (!types)
+ return false;
+ return Array.from(types).includes("Files");
+}
+function hasClipboardText(event) {
+ const data = event.clipboardData;
+ if (!data)
+ return false;
+ const types = Array.from(data.types || []);
+ return types.includes("text/plain") || types.includes("text/html") || typeof data.getData === "function" && data.getData("text/plain").length > 0;
+}
+function isTextLikeFile(file) {
+ if (file.type.startsWith("text/") || file.type === "application/json")
+ return true;
+ const extension = getFileExtension(file.name);
+ return extension !== "" && TEXT_FILE_EXTENSIONS.has(extension);
+}
+function mimeTypeFromName(fileName) {
+ return getFileExtension(fileName) === "json" ? "application/json" : "text/plain";
+}
+function getFileExtension(fileName) {
+ const index = fileName.lastIndexOf(".");
+ return index === -1 ? "" : fileName.slice(index + 1).toLowerCase();
+}
+function readFile(file, mode) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => {
+ var _a;
+ return resolve(String((_a = reader.result) !== null && _a !== void 0 ? _a : ""));
+ };
+ reader.onerror = () => {
+ var _a;
+ return reject((_a = reader.error) !== null && _a !== void 0 ? _a : new Error("Failed to read file"));
+ };
+ if (mode === "data-url") {
+ reader.readAsDataURL(file);
+ } else {
+ reader.readAsText(file);
+ }
+ });
+}
+
// src/storage-keys.ts
var STORAGE_KEYS = {
endpoints: "llm_fallbacks_proxy_endpoints",
guestToken: "llm_fallbacks_guest_token",
defaultModel: "llm_fallbacks_default_model",
- apiKeys: "llm_fallbacks_api_keys"
+ apiKeys: "llm_fallbacks_api_keys",
+ providerTiers: "llm_fallbacks_provider_tiers"
};
function loadJson(key, fallback) {
try {
@@ -5340,10 +5648,6 @@ function shouldTryBrowser(model, catalog, keys) {
}
return hasAnyKey(keys) && hasKeyForModel(model, keys);
}
-function shouldFallbackToProxy(browserErr) {
- const msg = browserErr.message || String(browserErr);
- return msg === "BROWSER_UNAVAILABLE" || msg === "PROXY_UNAVAILABLE" || /^no API key for /i.test(msg) || /^unsupported provider:/i.test(msg);
-}
// src/turnstile-session.ts
var siteKey;
@@ -5631,6 +5935,42 @@ function mapProxyChainFailure(lastError, rateLimit) {
return new ProxyUnavailableError(lastError);
}
+// src/providers/message-openai.ts
+function isImageFileBlock(block) {
+ return block.type === "file" && typeof block.mimeType === "string" && block.mimeType.startsWith("image/") && typeof block.data === "string";
+}
+function messageText(message) {
+ return message.blocks.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join("");
+}
+function messageHasImage(message) {
+ return message.blocks.some((b2) => isImageFileBlock(b2));
+}
+function messagesHaveImage(messages) {
+ return messages.some((m2) => messageHasImage(m2));
+}
+function messagesToOpenAi(messages) {
+ return messages.map((message) => {
+ const text = messageText(message);
+ const images = message.blocks.filter((b2) => isImageFileBlock(b2));
+ if (images.length === 0) {
+ return { role: message.role, content: text };
+ }
+ const parts = [];
+ if (text) parts.push({ type: "text", text });
+ for (const image of images) {
+ parts.push({ type: "image_url", image_url: { url: image.data } });
+ }
+ return { role: message.role, content: parts };
+ });
+}
+function messagesToPlainText(messages) {
+ return messages.map((message) => ({ role: message.role, content: messageText(message) }));
+}
+function modelSupportsVision(modelId, catalog) {
+ const entry = catalog.find((e) => e.id === modelId);
+ return entry?.supports_vision === true;
+}
+
// src/providers/routing-metadata.ts
var lastCompletionMeta = null;
var COMPLETION_META_EVENT = "llm-fallbacks:completion-meta";
@@ -5752,17 +6092,290 @@ function emitTextAsStreamEvents(text, onEvent) {
onEvent({ type: "finish", reason: "stop" });
}
+// src/providers/tiers/defaults.ts
+var TIER_IDS = [
+ "quality_api",
+ "web_ui",
+ "searxng_discovery",
+ "proxy_failover"
+];
+var DEFAULT_TIER_ENTRIES = [
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: false },
+ { id: "searxng_discovery", enabled: false },
+ { id: "proxy_failover", enabled: true }
+];
+var DEFAULT_ENABLED_BY_ID = new Map(
+ DEFAULT_TIER_ENTRIES.map((t) => [t.id, t.enabled])
+);
+function defaultProviderTierSettings() {
+ return {
+ tiers: DEFAULT_TIER_ENTRIES.map((t) => ({ ...t })),
+ webRunnerUrl: "",
+ searxngUrl: ""
+ };
+}
+function normalizeTierSettings(raw) {
+ const seen = /* @__PURE__ */ new Set();
+ const tiers = [];
+ for (const entry of raw.tiers ?? []) {
+ if (!DEFAULT_ENABLED_BY_ID.has(entry.id) || seen.has(entry.id)) continue;
+ seen.add(entry.id);
+ tiers.push({ id: entry.id, enabled: !!entry.enabled });
+ }
+ for (const id of TIER_IDS) {
+ if (!seen.has(id)) tiers.push({ id, enabled: DEFAULT_ENABLED_BY_ID.get(id) ?? false });
+ }
+ return {
+ tiers,
+ webRunnerUrl: raw.webRunnerUrl?.trim() ?? "",
+ searxngUrl: raw.searxngUrl?.trim() ?? ""
+ };
+}
+
+// src/providers/tiers/settings.ts
+function loadProviderTierSettings() {
+ const fallback = defaultProviderTierSettings();
+ const raw = loadJson(STORAGE_KEYS.providerTiers, fallback);
+ return normalizeTierSettings({
+ tiers: raw.tiers ?? fallback.tiers,
+ webRunnerUrl: raw.webRunnerUrl ?? "",
+ searxngUrl: raw.searxngUrl ?? ""
+ });
+}
+function saveProviderTierSettings(settings) {
+ saveJson(STORAGE_KEYS.providerTiers, normalizeTierSettings(settings));
+}
+
+// src/providers/tiers/types.ts
+var TierOrchestratorError = class extends Error {
+ attempts;
+ constructor(message, attempts) {
+ super(message);
+ this.name = "TierOrchestratorError";
+ this.attempts = attempts;
+ }
+};
+var TierSkipError = class extends Error {
+ tier;
+ constructor(tier, reason) {
+ super(reason);
+ this.name = "TierSkipError";
+ this.tier = tier;
+ }
+};
+
+// src/providers/tiers/orchestrator.ts
+function formatAttemptError(err) {
+ if (err instanceof Error) return err.message;
+ return String(err);
+}
+var TierOrchestrator = class {
+ constructor(handlers) {
+ this.handlers = handlers;
+ }
+ async streamChat(request, onEvent) {
+ const settings = loadProviderTierSettings();
+ const attempts = [];
+ for (const entry of settings.tiers) {
+ if (!entry.enabled) continue;
+ const handler = this.handlerFor(entry.id);
+ if (!handler) continue;
+ try {
+ await handler(request, onEvent);
+ return;
+ } catch (err) {
+ if (err instanceof TierSkipError) {
+ attempts.push({ tier: entry.id, error: err.message });
+ continue;
+ }
+ attempts.push({ tier: entry.id, error: formatAttemptError(err) });
+ if (request.signal.aborted) throw err;
+ }
+ }
+ const summary = attempts.map((a) => `${a.tier}: ${a.error}`).join("; ");
+ throw new TierOrchestratorError(
+ summary || "No provider tiers are enabled.",
+ attempts
+ );
+ }
+ handlerFor(id) {
+ switch (id) {
+ case "quality_api":
+ return this.handlers.qualityApi;
+ case "web_ui":
+ return this.handlers.webUi;
+ case "searxng_discovery":
+ return this.handlers.searxngDiscovery;
+ case "proxy_failover":
+ return this.handlers.proxyFailover;
+ default:
+ return null;
+ }
+ }
+};
+function qualityApiTierUnavailable() {
+ return new TierSkipError(
+ "quality_api",
+ "No BYOK API key set for the selected model \u2014 skipping direct routes."
+ );
+}
+function webUiTierUnavailable() {
+ return new TierSkipError("web_ui", "Web UI tier is not configured.");
+}
+function searxngTierUnavailable() {
+ return new TierSkipError("searxng_discovery", "SearXNG discovery is not configured.");
+}
+
+// src/providers/tiers/searxng-discovery-tier.ts
+var DiscoveryEmptyError = class extends Error {
+ constructor(query) {
+ super(`SearXNG returned no candidate free chat sites for "${query}".`);
+ this.name = "DiscoveryEmptyError";
+ }
+};
+var DiscoveryUnavailableError = class extends Error {
+ constructor(endpoint, cause) {
+ super(
+ `SearXNG at ${endpoint} is unreachable (${cause}). Check the URL in Tiers settings and that the instance allows browser requests (CORS).`
+ );
+ this.name = "DiscoveryUnavailableError";
+ }
+};
+var DEFAULT_DISCOVERY_QUERY = "free AI chat online no signup";
+var CHAT_HINT_RE = /\b(chat|gpt|assistant|llm|ai)\b/i;
+var EXCLUDED_HOST_RE = /(^|\.)(wikipedia\.org|youtube\.com|reddit\.com|github\.com|medium\.com|x\.com|twitter\.com|facebook\.com|linkedin\.com)$/i;
+var MAX_CANDIDATES = 6;
+function hostOf(url) {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return null;
+ }
+}
+function filterChatCandidates(results) {
+ const seenHosts = /* @__PURE__ */ new Set();
+ const candidates = [];
+ for (const result of results) {
+ const url = result.url?.trim() ?? "";
+ if (!url.startsWith("https://")) continue;
+ const host = hostOf(url);
+ if (!host || seenHosts.has(host) || EXCLUDED_HOST_RE.test(host)) continue;
+ const haystack = `${url} ${result.title ?? ""} ${result.content ?? ""}`;
+ if (!CHAT_HINT_RE.test(haystack)) continue;
+ seenHosts.add(host);
+ candidates.push({
+ url,
+ title: result.title?.trim() || host,
+ snippet: (result.content ?? "").trim().slice(0, 200)
+ });
+ if (candidates.length >= MAX_CANDIDATES) break;
+ }
+ return candidates;
+}
+function discoverySearchUrl(searxngUrl, query) {
+ const base = searxngUrl.replace(/\/$/, "");
+ return `${base}/search?q=${encodeURIComponent(query)}&format=json`;
+}
+async function searchFreeChatCandidates(options) {
+ const query = options.query?.trim() || DEFAULT_DISCOVERY_QUERY;
+ const doFetch = options.fetchImpl ?? fetch;
+ const url = discoverySearchUrl(options.searxngUrl, query);
+ let res;
+ try {
+ res = await doFetch(url, {
+ headers: { Accept: "application/json" },
+ signal: options.signal
+ });
+ } catch (err) {
+ if (options.signal?.aborted) throw err;
+ const cause = err instanceof Error ? err.message : String(err);
+ throw new DiscoveryUnavailableError(options.searxngUrl, cause || "network/CORS error");
+ }
+ if (!res.ok) {
+ throw new DiscoveryUnavailableError(options.searxngUrl, `HTTP ${res.status}`);
+ }
+ let parsed;
+ try {
+ parsed = await res.json();
+ } catch {
+ throw new DiscoveryUnavailableError(
+ options.searxngUrl,
+ "non-JSON response \u2014 enable the JSON format in SearXNG settings"
+ );
+ }
+ const candidates = filterChatCandidates(parsed.results ?? []);
+ if (candidates.length === 0) {
+ throw new DiscoveryEmptyError(query);
+ }
+ return candidates;
+}
+var DISCOVERY_RESULTS_EVENT = "llm-fallbacks:discovery-results";
+function broadcastDiscoveryResults(candidates) {
+ window.dispatchEvent(
+ new CustomEvent(DISCOVERY_RESULTS_EVENT, { detail: { candidates } })
+ );
+}
+
+// src/providers/tiers/web-ui-tier.ts
+var WebRunnerNotConfiguredError = class extends Error {
+ constructor(runnerUrl, detail) {
+ super(
+ `Web runner at ${runnerUrl} has no adapter configured (${detail}). Set up runner/runner.config.json \u2014 see runner/README.md.`
+ );
+ this.name = "WebRunnerNotConfiguredError";
+ }
+};
+var WebRunnerUnavailableError = class extends Error {
+ constructor(runnerUrl, cause) {
+ super(
+ `Web runner at ${runnerUrl} is unreachable (${cause}). Check that the runner is started and the URL in Tiers settings is correct.`
+ );
+ this.name = "WebRunnerUnavailableError";
+ }
+};
+function runnerChatUrl(runnerUrl) {
+ return `${runnerUrl.replace(/\/$/, "")}/v1/chat/completions`;
+}
+async function streamFromWebRunner(options) {
+ const doFetch = options.fetchImpl ?? fetch;
+ let res;
+ try {
+ res = await doFetch(runnerChatUrl(options.runnerUrl), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: options.model,
+ messages: options.messages,
+ max_tokens: options.maxTokens,
+ stream: true
+ }),
+ signal: options.signal
+ });
+ } catch (err) {
+ if (options.signal.aborted) throw err;
+ const cause = err instanceof Error ? err.message : String(err);
+ throw new WebRunnerUnavailableError(options.runnerUrl, cause || "network/CORS error");
+ }
+ if (res.status === 501) {
+ const bodyText = await res.text();
+ throw new WebRunnerNotConfiguredError(options.runnerUrl, bodyText.slice(0, 160) || "HTTP 501");
+ }
+ if (!res.ok) {
+ const bodyText = await res.text();
+ throw new WebRunnerUnavailableError(
+ options.runnerUrl,
+ `HTTP ${res.status} \u2014 ${bodyText.slice(0, 160)}`
+ );
+ }
+ await emitOpenAiSseAsStreamEvents(res, options.onEvent);
+}
+
// src/providers/FailoverProvider.ts
function endpointUrl(base) {
const trimmed = base.replace(/\/$/, "");
return trimmed.endsWith("/v1/chat/completions") ? trimmed : `${trimmed}/v1/chat/completions`;
}
-function messagesToOpenAi(messages) {
- return messages.map((m2) => {
- const text = m2.blocks.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join("");
- return { role: m2.role, content: text };
- });
-}
function readRoutingHeaders(res) {
const modelHeader = res.headers.get("x-litellm-model-name") || res.headers.get("x-litellm-model-id") || void 0;
const durationRaw = res.headers.get("x-litellm-response-duration-ms");
@@ -5924,86 +6537,144 @@ var FailoverProvider = class {
}
throw mapProxyChainFailure(lastError, lastRateLimit);
}
- async streamChat(request, onEvent) {
+ buildChatBody(request) {
const config = this.getRuntimeConfig();
const model = this.resolveModel(request);
- const openAiMessages = messagesToOpenAi(request.messages);
const body = {
model,
- messages: openAiMessages,
+ messages: messagesToOpenAi(request.messages),
max_tokens: request.options.max_tokens ?? config.maxTokens
};
- const keys = loadKeys();
- const userKeys = keys;
- const tryBrowser = async (onEv) => {
- const result = await chatWithBrowserFallback({
- model,
- messages: openAiMessages,
- maxTokens: body.max_tokens,
- catalog: this.catalog,
- providerUrls: this.providerUrls,
- keys: userKeys,
- onStatus: (s) => this.setStatus(s)
- });
- this.lastRoute = result.route;
- window.LLM_FALLBACKS_ROUTE = result.route;
- setLastCompletionMeta({
- endpoint: result.route,
- fallbackCount: 0
- });
- emitTextAsStreamEvents(result.content, onEv);
+ return {
+ config,
+ model,
+ body,
+ plainMessages: messagesToPlainText(request.messages),
+ hasImage: messagesHaveImage(request.messages)
};
- if (config.endpoints.length) {
- try {
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback(body, config, onEv, request.signal),
- onEvent
- );
- return;
- } catch (proxyErr) {
- if (!shouldTryBrowser(model, this.catalog, userKeys)) throw proxyErr;
- this.setStatus("cloud proxy unavailable \u2014 trying optional browser route \u2026");
+ }
+ // quality_api tier: direct/BYOK provider routes only. Disjoint from
+ // proxy_failover (KTD7) — this tier never touches the cloud proxy chain, so
+ // a single request is not attempted twice against the same endpoint. Without
+ // a usable key it skips, letting the orchestrator advance to proxy_failover.
+ async streamQualityApiRoute(request, onEvent) {
+ const { model, body, plainMessages, hasImage } = this.buildChatBody(request);
+ const userKeys = loadKeys();
+ if (hasImage) {
+ throw new TierSkipError(
+ "quality_api",
+ "Direct BYOK routes do not support image attachments yet \u2014 using the proxy tier."
+ );
+ }
+ if (!shouldTryBrowser(model, this.catalog, userKeys)) {
+ throw qualityApiTierUnavailable();
+ }
+ const result = await chatWithBrowserFallback({
+ model,
+ messages: plainMessages,
+ maxTokens: body.max_tokens,
+ catalog: this.catalog,
+ providerUrls: this.providerUrls,
+ keys: userKeys,
+ onStatus: (s) => this.setStatus(s)
+ });
+ this.lastRoute = result.route;
+ window.LLM_FALLBACKS_ROUTE = this.lastRoute;
+ setLastCompletionMeta({
+ endpoint: result.route,
+ fallbackCount: 0
+ });
+ emitTextAsStreamEvents(result.content, onEvent);
+ }
+ async streamProxyFailoverRoute(request, onEvent) {
+ const { config, body } = this.buildChatBody(request);
+ await this.streamProxyFallback(body, config, onEvent, request.signal);
+ }
+ async streamWebUiRoute(request, onEvent) {
+ const settings = loadProviderTierSettings();
+ if (!settings.webRunnerUrl) {
+ throw webUiTierUnavailable();
+ }
+ const { model, body, plainMessages, hasImage } = this.buildChatBody(request);
+ if (hasImage) {
+ throw new TierSkipError(
+ "web_ui",
+ "The web runner does not support image attachments \u2014 using the proxy tier."
+ );
+ }
+ this.setStatus(`web runner: ${settings.webRunnerUrl} \u2026`);
+ let metaSet = false;
+ await streamFromWebRunner({
+ runnerUrl: settings.webRunnerUrl,
+ model,
+ messages: plainMessages,
+ maxTokens: body.max_tokens,
+ signal: request.signal,
+ onEvent: (event) => {
+ if (!metaSet) {
+ metaSet = true;
+ this.lastRoute = `web_ui/${settings.webRunnerUrl}`;
+ window.LLM_FALLBACKS_ROUTE = this.lastRoute;
+ setLastCompletionMeta({ endpoint: this.lastRoute, fallbackCount: 0 });
+ }
+ onEvent(event);
}
+ });
+ }
+ async streamSearxngDiscoveryRoute(request, _onEvent) {
+ const settings = loadProviderTierSettings();
+ if (!settings.searxngUrl) {
+ throw searxngTierUnavailable();
}
- if (model !== "free" && !shouldTryBrowser(model, this.catalog, userKeys)) {
- if (config.endpoints.length) {
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback({ ...body, model: "free" }, config, onEv, request.signal),
- onEvent
+ this.setStatus("searxng: searching for free chat sites \u2026");
+ const candidates = await searchFreeChatCandidates({
+ searxngUrl: settings.searxngUrl,
+ signal: request.signal
+ });
+ broadcastDiscoveryResults(candidates);
+ throw new Error(
+ `SearXNG found ${candidates.length} candidate chat site${candidates.length === 1 ? "" : "s"} \u2014 see suggestions below the chat.`
+ );
+ }
+ async streamChat(request, onEvent) {
+ if (messagesHaveImage(request.messages)) {
+ const model = this.resolveModel(request);
+ if (!modelSupportsVision(model, this.catalog)) {
+ throw new ChatRouteError(
+ "vision_unsupported",
+ `"${model}" can't read images. Pick a vision-capable model (filter by vision in the model explorer) or remove the attachment.`
);
- return;
}
- throw new Error(
- "Selected model requires an API key for its provider. Choose free or add the provider key in Settings."
- );
}
- if (shouldTryBrowser(model, this.catalog, userKeys)) {
- try {
- await this.streamWithCompletionTracking((onEv) => tryBrowser(onEv), onEvent);
- return;
- } catch (browserErr) {
- const err = browserErr instanceof Error ? browserErr : new Error(String(browserErr));
- if (config.endpoints.length && shouldFallbackToProxy(err)) {
- this.setStatus("browser route failed \u2014 retrying cloud proxy \u2026");
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback(body, config, onEv, request.signal),
- onEvent
- );
- return;
- }
- throw err;
+ const orchestrator = new TierOrchestrator({
+ qualityApi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv),
+ webUi: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv),
+ searxngDiscovery: (req, onEv) => this.streamWithCompletionTracking(
+ (inner) => this.streamSearxngDiscoveryRoute(req, inner),
+ onEv
+ ),
+ proxyFailover: (req, onEv) => this.streamWithCompletionTracking((inner) => this.streamProxyFailoverRoute(req, inner), onEv)
+ });
+ try {
+ await orchestrator.streamChat(request, onEvent);
+ } catch (err) {
+ if (err instanceof TierOrchestratorError) {
+ throw this.mapTierFailure(err);
}
+ throw err;
}
- if (config.endpoints.length) {
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback(body, config, onEv, request.signal),
- onEvent
+ }
+ // Preserve the ChatRouteError taxonomy (rate-limit / quota / cold-start) while
+ // surfacing which tiers were tried and their last error (R40). A no-attempt
+ // failure means every tier skipped or none were enabled.
+ mapTierFailure(err) {
+ if (err.attempts.length === 0) {
+ return mapProxyChainFailure(
+ "No chat routes are available yet. Enable a provider tier in Settings, or wait for the demo proxy to finish deploying."
);
- return;
}
- throw mapProxyChainFailure(
- "No chat routes are available yet. The demo proxy is still deploying \u2014 refresh in a minute."
- );
+ const summary = err.attempts.map((a) => `${a.tier} \u2192 ${a.error}`).join(" | ");
+ return mapProxyChainFailure(summary);
}
};
@@ -6240,6 +6911,443 @@ function ByokSettingsPlugin(deps) {
};
}
+// src/plugins/tier-settings/settings.ts
+var TIER_LABELS = {
+ quality_api: "Direct / BYOK routes",
+ web_ui: "Local web-UI runner (opt-in)",
+ searxng_discovery: "SearXNG discovery (opt-in)",
+ proxy_failover: "Cloud proxy failover"
+};
+var TIER_HINTS = {
+ quality_api: "Uses API keys stored in this browser. Skips when no key matches the selected model.",
+ web_ui: "Optional local companion that drives a browser chat UI. Off by default \u2014 you run it.",
+ searxng_discovery: "Optional self-hosted SearXNG. Suggests free chat URLs when higher tiers fail.",
+ proxy_failover: "Public Worker / Render endpoints from Server settings. Serves zero-config visitors."
+};
+function moveTier(settings, tierId, delta) {
+ const tiers = settings.tiers.map((t) => ({ ...t }));
+ const from = tiers.findIndex((t) => t.id === tierId);
+ if (from < 0) return settings;
+ const to = from + delta;
+ if (to < 0 || to >= tiers.length) return settings;
+ const [entry] = tiers.splice(from, 1);
+ tiers.splice(to, 0, entry);
+ return normalizeTierSettings({ ...settings, tiers });
+}
+function setTierEnabled(settings, tierId, enabled) {
+ const tiers = settings.tiers.map(
+ (t) => t.id === tierId ? { ...t, enabled } : { ...t }
+ );
+ return normalizeTierSettings({ ...settings, tiers });
+}
+function updateCompanionUrls(settings, urls) {
+ return normalizeTierSettings({
+ ...settings,
+ webRunnerUrl: urls.webRunnerUrl ?? settings.webRunnerUrl,
+ searxngUrl: urls.searxngUrl ?? settings.searxngUrl
+ });
+}
+function persistTierSettings(settings) {
+ const normalized = normalizeTierSettings(settings);
+ saveProviderTierSettings(normalized);
+ return loadProviderTierSettings();
+}
+
+// src/plugins/tier-settings/index.ts
+function renderTierList(settings) {
+ return settings.tiers.map((tier, index) => {
+ const label = TIER_LABELS[tier.id] ?? tier.id;
+ const hint = TIER_HINTS[tier.id] ?? "";
+ return `
+
+
+
+
+ ${label}
+
+
+ \u2191
+ \u2193
+
+
+ ${hint}
+
+ `;
+ }).join("");
+}
+function TierSettingsPlugin() {
+ return {
+ name: "tier-settings",
+ onMount() {
+ window.registerShellPanel?.("tiers", (root) => {
+ let draft = loadProviderTierSettings();
+ const paint = () => {
+ root.innerHTML = `
+
+
+ Ordered routes we try for each chat. This is the omnifail stack
+ (which route to attempt), not cloud free-tier rate limits.
+ Exhausting enabled tiers still fails honestly \u2014 we do not promise never-fail.
+
+ ${renderTierList(draft)}
+ Web-UI runner URL
+
+
+
+ Opt-in local companion. Off by default. You run it; it lowers pressure on the
+ public Worker demo. You are responsible for target-site terms \u2014 we do not
+ harvest credentials.
+
+ SearXNG URL
+
+
+
+ Opt-in self-hosted search. Empty disables discovery. Respect SearXNG and
+ target-site terms of service.
+
+
+ Reset defaults
+ Save tiers
+
+
+ `;
+ const list = root.querySelector("#lf-tier-list");
+ list?.addEventListener("click", (event) => {
+ const target = event.target;
+ const up = target.closest("[data-tier-up]");
+ const down = target.closest("[data-tier-down]");
+ if (up?.dataset.tierUp) {
+ draft = moveTier(draft, up.dataset.tierUp, -1);
+ paint();
+ return;
+ }
+ if (down?.dataset.tierDown) {
+ draft = moveTier(draft, down.dataset.tierDown, 1);
+ paint();
+ }
+ });
+ list?.addEventListener("change", (event) => {
+ const input = event.target;
+ const tierId = input.dataset.tierEnable;
+ if (!tierId || input.type !== "checkbox") return;
+ draft = setTierEnabled(draft, tierId, input.checked);
+ });
+ root.querySelector("#lf-tier-reset")?.addEventListener("click", () => {
+ draft = persistTierSettings(defaultProviderTierSettings());
+ paint();
+ const status = root.querySelector("#lf-tier-status");
+ if (status) status.textContent = "Restored zero-config defaults.";
+ });
+ root.querySelector("#lf-tier-save")?.addEventListener("click", () => {
+ const webRunnerUrl = root.querySelector("#lf-web-runner-url")?.value ?? "";
+ const searxngUrl = root.querySelector("#lf-searxng-url")?.value ?? "";
+ draft = updateCompanionUrls(draft, { webRunnerUrl, searxngUrl });
+ draft = persistTierSettings(draft);
+ paint();
+ const status = root.querySelector("#lf-tier-status");
+ if (status) {
+ status.textContent = `Saved order: ${draft.tiers.filter((t) => t.enabled).map((t) => t.id).join(" \u2192 ") || "(none enabled)"}`;
+ }
+ });
+ };
+ paint();
+ });
+ }
+ };
+}
+
+// src/plugins/compare-mode/column-provider.ts
+function defaultCompareState(activeModel = "free") {
+ return {
+ active: false,
+ columns: {
+ a: { model: activeModel || "free" },
+ b: { model: "openrouter/free" }
+ }
+ };
+}
+function columnIsMetered(model, catalog, keys = loadKeys()) {
+ if (model === "free") return true;
+ return !shouldTryBrowser(model, catalog, keys);
+}
+function bothColumnsMetered(state, catalog, keys = loadKeys()) {
+ if (!state.active) return false;
+ return columnIsMetered(state.columns.a.model, catalog, keys) && columnIsMetered(state.columns.b.model, catalog, keys);
+}
+var METERED_COMPARE_BANNER = "Compare sends two requests. Rate limits and Turnstile apply to each column when both use the public proxy.";
+
+// src/plugins/compare-mode/index.ts
+function modelOptionsHtml(selected) {
+ const parts = [];
+ for (const pinned of getPinnedModels()) {
+ parts.push(
+ `${pinned.label} `
+ );
+ }
+ for (const entry of getCatalogModels(40)) {
+ if (getPinnedModels().some((p) => p.id === entry.id)) continue;
+ parts.push(
+ `${modelOptionLabel(entry)} `
+ );
+ }
+ return parts.join("");
+}
+function applyDeltaToPane(pane, event) {
+ if (event.type === "message_start") {
+ pane.textContent = "";
+ return;
+ }
+ if (event.type === "text_delta") {
+ pane.textContent = (pane.textContent || "") + event.delta;
+ }
+}
+function tagCompareMeta(event, column, model) {
+ if (event.type !== "message_start") return event;
+ return {
+ ...event,
+ message: {
+ ...event.message,
+ meta: { ...event.message.meta || {}, compareColumn: column, model }
+ }
+ };
+}
+function CompareModePlugin(deps) {
+ let state = defaultCompareState(getActiveModel());
+ let mount = null;
+ let chrome = null;
+ let bannerEl = null;
+ let paneA = null;
+ let paneB = null;
+ let labelA = null;
+ let labelB = null;
+ let wrapped = false;
+ const refreshBanner = () => {
+ if (!bannerEl) return;
+ const show = bothColumnsMetered(state, deps.getCatalog());
+ bannerEl.hidden = !show;
+ bannerEl.textContent = show ? METERED_COMPARE_BANNER : "";
+ };
+ const syncChromeVisibility = () => {
+ if (!chrome || !mount) return;
+ chrome.hidden = !state.active;
+ mount.classList.toggle("lf-compare-active", state.active);
+ refreshBanner();
+ };
+ const paintColumnLabels = () => {
+ if (labelA) labelA.textContent = state.columns.a.model;
+ if (labelB) labelB.textContent = state.columns.b.model;
+ };
+ return {
+ name: "compare-mode",
+ onMount(ctx) {
+ mount = ctx.container;
+ chrome = document.createElement("div");
+ chrome.className = "lf-compare-chrome";
+ chrome.hidden = true;
+ chrome.innerHTML = `
+
+
+ `;
+ const layout = mount.querySelector(".mur-chat-layout-wrapper");
+ const formHost = mount.querySelector(".mur-chat-form-container");
+ if (layout && formHost) {
+ layout.insertBefore(chrome, formHost);
+ } else {
+ (mount.querySelector(".mur-chat-scroll-area") || mount).appendChild(chrome);
+ }
+ bannerEl = chrome.querySelector("#lf-compare-banner");
+ paneA = chrome.querySelector('[data-pane="a"]');
+ paneB = chrome.querySelector('[data-pane="b"]');
+ labelA = chrome.querySelector('[data-label="a"]');
+ labelB = chrome.querySelector('[data-label="b"]');
+ const selectA = chrome.querySelector('select[data-column="a"]');
+ const selectB = chrome.querySelector('select[data-column="b"]');
+ selectA.innerHTML = modelOptionsHtml(state.columns.a.model);
+ selectB.innerHTML = modelOptionsHtml(state.columns.b.model);
+ selectA.addEventListener("change", () => {
+ state.columns.a.model = selectA.value;
+ paintColumnLabels();
+ refreshBanner();
+ });
+ selectB.addEventListener("change", () => {
+ state.columns.b.model = selectB.value;
+ paintColumnLabels();
+ refreshBanner();
+ });
+ paintColumnLabels();
+ if (formHost) {
+ const toggleRow = document.createElement("div");
+ toggleRow.className = "lf-compare-toggle-row";
+ toggleRow.innerHTML = `
+
+
+ Compare mode
+
+ Same prompt \u2192 two models side by side
+ `;
+ formHost.insertBefore(toggleRow, formHost.firstChild);
+ const checkbox = toggleRow.querySelector("#lf-compare-toggle");
+ checkbox.addEventListener("change", () => {
+ state.active = checkbox.checked;
+ if (state.active) {
+ state.columns.a.model = selectA.value || getActiveModel();
+ selectA.value = state.columns.a.model;
+ paintColumnLabels();
+ showStatusMessage("Compare mode on \u2014 replies appear in both columns.");
+ } else {
+ showStatusMessage("Compare mode off \u2014 history kept.");
+ }
+ syncChromeVisibility();
+ });
+ }
+ syncChromeVisibility();
+ if (!wrapped) {
+ wrapped = true;
+ const original = deps.provider.streamChat.bind(deps.provider);
+ deps.provider.streamChat = async (request, onEvent) => {
+ if (!state.active) {
+ return original(request, onEvent);
+ }
+ refreshBanner();
+ if (paneA) paneA.textContent = "";
+ if (paneB) paneB.textContent = "";
+ paintColumnLabels();
+ const modelA = state.columns.a.model;
+ const modelB = state.columns.b.model;
+ const reqA = {
+ ...request,
+ options: { ...request.options, model: modelA }
+ };
+ const reqB = {
+ ...request,
+ options: { ...request.options, model: modelB }
+ };
+ try {
+ await original(reqA, (event) => {
+ if (paneA) applyDeltaToPane(paneA, event);
+ onEvent(tagCompareMeta(event, "a", modelA));
+ });
+ } catch (err) {
+ if (paneA && !paneA.textContent) {
+ paneA.textContent = err instanceof Error ? err.message : String(err);
+ }
+ throw err;
+ }
+ try {
+ await original(reqB, (event) => {
+ if (paneB) applyDeltaToPane(paneB, event);
+ onEvent(tagCompareMeta(event, "b", modelB));
+ });
+ } catch (err) {
+ if (paneB && !paneB.textContent) {
+ paneB.textContent = err instanceof Error ? err.message : String(err);
+ }
+ }
+ };
+ }
+ },
+ beforeSubmit: async (request) => {
+ if (!state.active) return;
+ refreshBanner();
+ return {
+ options: {
+ ...request.options,
+ model: state.columns.a.model,
+ lfCompare: true
+ }
+ };
+ },
+ destroy() {
+ chrome?.remove();
+ mount?.classList.remove("lf-compare-active");
+ }
+ };
+}
+
+// src/plugins/discovery-picklist/index.ts
+function escapeHtml(text) {
+ return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """);
+}
+function candidateRow(candidate) {
+ const title = escapeHtml(candidate.title);
+ const url = escapeHtml(candidate.url);
+ const snippet = escapeHtml(candidate.snippet);
+ return `
+
+ ${title}
+ ${url}
+ ${snippet ? `${snippet}
` : ""}
+
+ `;
+}
+function DiscoveryPicklistPlugin() {
+ let host = null;
+ let handler = null;
+ return {
+ name: "discovery-picklist",
+ onMount(ctx) {
+ host = document.createElement("div");
+ host.className = "lf-discovery-picklist";
+ host.hidden = true;
+ const layout = ctx.container.querySelector(".mur-chat-layout-wrapper");
+ const form = ctx.container.querySelector(".mur-chat-form-container");
+ if (layout && form) {
+ layout.insertBefore(host, form);
+ } else {
+ ctx.container.appendChild(host);
+ }
+ handler = (event) => {
+ const detail = event.detail;
+ const candidates = detail?.candidates ?? [];
+ if (!host || candidates.length === 0) return;
+ host.innerHTML = `
+
+
+ ${candidates.map((c) => candidateRow(c)).join("")}
+
+ `;
+ host.hidden = false;
+ host.querySelector(".lf-discovery-dismiss")?.addEventListener("click", () => {
+ if (host) host.hidden = true;
+ });
+ };
+ window.addEventListener(DISCOVERY_RESULTS_EVENT, handler);
+ },
+ destroy() {
+ if (handler) window.removeEventListener(DISCOVERY_RESULTS_EVENT, handler);
+ host?.remove();
+ }
+ };
+}
+
// src/catalog-display.ts
function formatContextLength(value) {
if (value === void 0 || value <= 0) return "\u2014";
@@ -6394,25 +7502,25 @@ function ModelExplorerPlugin(deps) {
}
function cellHtml(row, col) {
if (col.display === "context") {
- return escapeHtml(formatContextLength(row.context_length));
+ return escapeHtml2(formatContextLength(row.context_length));
}
if (col.display === "capabilities") {
return renderCapabilityBadgesHtml(row) || "\u2014";
}
if (col.key === "provider") {
const provider = row.provider ?? String(row.id).split("/")[0] ?? "";
- return escapeHtml(provider);
+ return escapeHtml2(provider);
}
- return escapeHtml(String(row[col.key] ?? ""));
+ return escapeHtml2(String(row[col.key] ?? ""));
}
function renderTable(rows) {
thead.innerHTML = `${TABLE_COLUMNS.map(
(c) => `${c.label}${sortColumn === c.key ? sortDir === "asc" ? " \u25B2" : " \u25BC" : ""} `
).join("")}Use `;
tbody.innerHTML = rows.slice(0, 200).map(
- (row, rowIdx) => `${TABLE_COLUMNS.map(
+ (row, rowIdx) => ` ${TABLE_COLUMNS.map(
(c) => `${cellHtml(row, c)} `
- ).join("")}Use for chat `
+ ).join("")}Use for chat `
).join("");
statusEl.textContent = `${rows.length} model(s) shown${rows.length > 200 ? " (first 200)" : ""}`;
thead.querySelectorAll("th").forEach((th) => {
@@ -6467,7 +7575,7 @@ function ModelExplorerPlugin(deps) {
}
};
}
-function escapeHtml(s) {
+function escapeHtml2(s) {
return s.replace(/&/g, "&").replace(//g, ">");
}
@@ -6767,6 +7875,7 @@ function closeShellPanel(_id) {
function bindTopBarButtons() {
document.getElementById("sysSetting")?.addEventListener("click", () => openShellPanel("failover"));
document.getElementById("byokSetting")?.addEventListener("click", () => openShellPanel("byok"));
+ document.getElementById("tiersSetting")?.addEventListener("click", () => openShellPanel("tiers"));
document.getElementById("explorerSetting")?.addEventListener("click", () => openShellPanel("explorer"));
document.getElementById("closeSet")?.addEventListener("click", () => closeShellPanel());
document.getElementById("sysMask")?.addEventListener("click", (e) => {
@@ -6775,14 +7884,14 @@ function bindTopBarButtons() {
}
// src/export-session.ts
-function messageText(message) {
+function messageText2(message) {
return message.blocks.filter((b2) => b2.type === "text").map((b2) => b2.type === "text" ? b2.text : "").join("").trim();
}
function toMarkdown(messages, meta) {
const title = meta?.title?.trim() || "Chat export";
const lines = [`# ${title}`, "", `_Exported from llm-fallbacks_`, ""];
for (const message of messages) {
- const text = messageText(message);
+ const text = messageText2(message);
if (!text) continue;
const heading = message.role === "user" ? "## User" : "## Assistant";
lines.push(heading, "", text, "");
@@ -6798,7 +7907,7 @@ function toJson(messages, meta) {
messages: messages.map((m2) => ({
id: m2.id,
role: m2.role,
- text: messageText(m2)
+ text: messageText2(m2)
}))
},
null,
@@ -7032,6 +8141,7 @@ function ShortcutsSheetPlugin() {
}
// src/main.ts
+var MAX_IMAGE_ATTACHMENT_BYTES = 4e6;
async function loadCatalog(config) {
let catalog = [];
let providerUrls = {};
@@ -7182,6 +8292,19 @@ async function bootstrap() {
},
plugins: (engine) => [
CopyPlugin(),
+ AttachmentPlugin({
+ acceptedTypes: "image/*",
+ maxFileSize: MAX_IMAGE_ATTACHMENT_BYTES,
+ onSizeExceeded: (file, maxSize) => {
+ const limitMb = Math.round(maxSize / 1e6);
+ showStatusMessage(
+ `"${file.name}" is too large. Images must be under ${limitMb} MB.`
+ );
+ },
+ onUnsupportedFile: (file) => {
+ showStatusMessage(`"${file.name}" isn't a supported image type.`);
+ }
+ }),
ModelPickerPlugin(),
MessageActionsPlugin(),
RoutingChipPlugin(),
@@ -7204,6 +8327,12 @@ async function bootstrap() {
provider.setCatalog(catalogRef2, providerUrlsRef);
}
}),
+ TierSettingsPlugin(),
+ CompareModePlugin({
+ provider,
+ getCatalog: () => catalogRef2
+ }),
+ DiscoveryPicklistPlugin(),
ModelExplorerPlugin({
getCatalog: () => catalogRef2,
getCatalogUrl: () => readRuntimeConfig().catalogUrl
diff --git a/docs/assets/shell/chat-overrides.css b/docs/assets/shell/chat-overrides.css
index ad20d36..f5a1205 100644
--- a/docs/assets/shell/chat-overrides.css
+++ b/docs/assets/shell/chat-overrides.css
@@ -960,3 +960,255 @@ body.lf-chat-page::before {
color: #c77dff;
}
+/* Wave 4B: image attachment tray — inherits murm-ui attachment vars from the
+ dark theme block above; these rules just tighten thumbnail framing so the
+ composer tray matches the dark shell. */
+#chatMount .mur-attachment-previews {
+ gap: 0.4rem;
+}
+
+#chatMount .mur-attachment-preview-item img,
+#chatMount .mur-attachment-image {
+ border-radius: 8px;
+ border: 1px solid var(--mur-border);
+}
+
+/* Wave 4B: provider tier settings panel */
+.lf-tier-list {
+ list-style: none;
+ margin: 0 0 1rem;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.lf-tier-row {
+ border: 1px solid rgba(157, 78, 221, 0.22);
+ border-radius: 10px;
+ padding: 0.65rem 0.75rem;
+ background: rgba(26, 26, 46, 0.55);
+}
+
+.lf-tier-row-main {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.lf-tier-enable {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+ font-weight: 600;
+ color: #e8e8ef;
+}
+
+.lf-tier-enable input {
+ width: auto;
+ margin: 0;
+}
+
+.lf-tier-move {
+ display: flex;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.lf-tier-move .panel-btn {
+ min-width: 2rem;
+ padding: 0.2rem 0.45rem;
+}
+
+.lf-tier-hint {
+ margin: 0.35rem 0 0;
+}
+
+/* Wave 4B: compare mode split-pane */
+.lf-compare-toggle-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ padding: 0.35rem 0.15rem 0.55rem;
+}
+
+.lf-compare-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin: 0;
+ font-weight: 600;
+ color: #e8e8ef;
+ cursor: pointer;
+}
+
+.lf-compare-toggle input {
+ width: auto;
+ margin: 0;
+}
+
+.lf-compare-toggle-hint {
+ font-size: 0.78rem;
+ color: #8b8ba3;
+}
+
+.lf-compare-chrome {
+ padding: 0.5rem 0.75rem 0.85rem;
+ border-top: 1px solid rgba(157, 78, 221, 0.22);
+ background: rgba(18, 18, 31, 0.92);
+ flex-shrink: 0;
+}
+
+.lf-compare-chrome[hidden] {
+ display: none !important;
+}
+
+.lf-compare-banner {
+ margin: 0 0 0.65rem;
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+ border: 1px solid rgba(248, 113, 113, 0.35);
+ background: rgba(127, 29, 29, 0.28);
+ color: #fecaca;
+ font-size: 0.82rem;
+}
+
+.lf-compare-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+}
+
+@media (max-width: 720px) {
+ .lf-compare-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.lf-compare-column {
+ border: 1px solid rgba(157, 78, 221, 0.22);
+ border-radius: 10px;
+ background: rgba(26, 26, 46, 0.65);
+ min-height: 8rem;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.lf-compare-column-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.55rem 0.65rem;
+ border-bottom: 1px solid rgba(157, 78, 221, 0.18);
+}
+
+.lf-compare-column-title {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #8b8ba3;
+ margin-bottom: 0.2rem;
+}
+
+.lf-compare-model {
+ width: 100%;
+ max-width: 100%;
+}
+
+.lf-compare-live-label {
+ font-size: 0.75rem;
+ color: #c77dff;
+ word-break: break-all;
+}
+
+.lf-compare-pane {
+ flex: 1;
+ padding: 0.65rem 0.75rem;
+ white-space: pre-wrap;
+ color: #e8e8ef;
+ font-size: 0.92rem;
+ line-height: 1.45;
+ min-height: 5rem;
+}
+
+.lf-compare-pane:empty::before {
+ content: "Waiting for reply…";
+ color: #8b8ba3;
+ font-style: italic;
+}
+
+#chatMount.lf-compare-active .mur-chat-history {
+ /* Keep history visible above the live compare panes */
+ max-height: 42vh;
+}
+
+/* Wave 4B: SearXNG discovery pick list */
+.lf-discovery-picklist {
+ flex-shrink: 0;
+ padding: 0.55rem 0.75rem;
+ border-top: 1px solid rgba(157, 78, 221, 0.22);
+ background: rgba(18, 18, 31, 0.92);
+}
+
+.lf-discovery-picklist[hidden] {
+ display: none !important;
+}
+
+.lf-discovery-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ color: #c4c4d4;
+}
+
+.lf-discovery-dismiss {
+ min-width: 1.8rem;
+ padding: 0.1rem 0.4rem;
+}
+
+.lf-discovery-list {
+ list-style: none;
+ margin: 0.5rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.lf-discovery-item {
+ border: 1px solid rgba(157, 78, 221, 0.22);
+ border-radius: 8px;
+ padding: 0.5rem 0.65rem;
+ background: rgba(26, 26, 46, 0.6);
+}
+
+.lf-discovery-item a {
+ color: #c77dff;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.lf-discovery-item a:hover {
+ text-decoration: underline;
+}
+
+.lf-discovery-url {
+ display: block;
+ font-size: 0.72rem;
+ color: #8b8ba3;
+ word-break: break-all;
+}
+
+.lf-discovery-snippet {
+ margin: 0.3rem 0 0;
+ font-size: 0.8rem;
+ color: #c4c4d4;
+}
+
diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md
new file mode 100644
index 0000000..af92326
--- /dev/null
+++ b/docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md
@@ -0,0 +1,130 @@
+---
+title: Chat UI Wave 4B — vision, compare, and omnifail provider tiers
+date: 2026-07-25
+status: confirmed
+priority_wave: wave4b-differentiation
+origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md
+prior_waves: [wave1, wave2, wave3, wave4]
+strategy: STRATEGY.md
+---
+
+# Chat UI Wave 4B — vision, compare, and omnifail provider tiers
+
+## Summary
+
+Extend the static chat demo with **vision attachments**, **two-column model compare**, and a **user-configurable provider tier stack** that prioritizes the highest-quality free paths first, then optional headless web-UI automation, then existing proxy/BYOK failover. When configured tiers exhaust, **SearXNG discovery** proposes new free web chat targets before giving up — aligned with the repo goal that a response should almost never fail for lack of a route.
+
+## Problem Frame
+
+Waves 1–4 delivered table-stakes UX (picker, routing chip, export/import, streaming polish). The homepage still loses to ChatGPT-class tools on **multimodal** and **model shopping** — visitors cannot attach images or ask the same prompt of two models side-by-side. Separately, the product thesis is **omnifail routing**: quality-first, exhaustive fallback. Today `FailoverProvider` covers proxy → BYOK HTTP only; it does not orchestrate headless free web UIs or discover new targets when the chain is exhausted.
+
+Demand for Wave 4B is **speculative** (no observed visitor workaround yet); success is measured by demo parity with multimodal/compare expectations and by depth of configurable failover, not traffic proof.
+
+## Requirements
+
+### Multimodal (vision)
+
+| ID | Requirement |
+|----|-------------|
+| R28 | Users **attach one or more images** (paste, file picker, or drag) in the composer when the selected route supports vision; thumbnails preview before send. |
+| R29 | Non-vision routes **block send or warn clearly** when images are attached — images are never silently dropped. |
+| R30 | Vision-capable catalog models (already badged in explorer/picker) are **preferred or filtered** when attachments are present. |
+| R31 | Image payloads respect a **published size cap** (client-side); oversize files show a clear error without corrupting the session. |
+
+### Compare mode
+
+| ID | Requirement |
+|----|-------------|
+| R32 | Users enter **compare mode**: same user prompt (including attachments when supported) sent to **two independently configured sources** shown in a two-column layout. |
+| R33 | Each column shows **model/source label**, streaming reply, and routing metadata consistent with the routing chip pattern. |
+| R34 | Compare mode **surfaces rate-limit and Turnstile cost** (two requests) before send when both columns use metered routes. |
+| R35 | Users can **exit compare mode** back to single-column chat without losing session history. |
+
+### Provider tiers (omnifail stack)
+
+| ID | Requirement |
+|----|-------------|
+| R36 | Users configure an **ordered provider tier list** persisted in browser storage; default order ships sensible for zero-config demo (quality API routes before exotic tiers). |
+| R37 | **Tier: quality API** — existing proxy SSE and BYOK HTTP OpenAI-compatible calls, ranked by user order and catalog quality score where applicable. |
+| R38 | **Tier: headless web UI** (optional, off by default) — user-supplied runner (local or self-hosted) automates free web chat UIs via headless browser; public Pages demo does not require this tier to function. |
+| R39 | **Tier: SearXNG discovery** — when higher tiers fail or are disabled, query a user-configurable SearXNG instance to find candidate **free web chat URLs**; discovered targets feed the web-UI tier or present as suggested links — not silent auto-login to third-party accounts. |
+| R40 | Failover walks the tier list until a response succeeds or all tiers exhaust; final failure shows **actionable diagnostics** (which tiers were tried, last error class). |
+| R41 | Local/self-hosted runner mode is **explicitly opt-in** and documented as lower rate-limit pressure than the public Worker demo. |
+
+### Trust, ops, and demo constraints
+
+| ID | Requirement |
+|----|-------------|
+| R42 | Web-UI and SearXNG tiers include **CAVEATS copy**: user responsibility for target site terms, no credential harvesting, no default enablement on the public homepage without operator config. |
+| R43 | Wave 4B features degrade gracefully when optional backends are absent — zero-config text chat via proxy remains unchanged. |
+
+## Approaches considered
+
+### A. Client-only (vision + compare on existing proxy)
+
+Ship R28–R35 using today’s `FailoverProvider` only. **Pros:** smallest diff, static Pages only. **Cons:** ignores omnifail tier vision and SearXNG.
+
+### B. Differentiation + tier stack (recommended)
+
+Ship R28–R35 plus R36–R43: UI features on static Pages; tier orchestration in browser with optional local/edge runners for web-UI and SearXNG. **Pros:** matches user priority (Playwright web UIs → BYOK → proxy) while keeping demo static-first. **Cons:** web tier and discovery need follow-on implementation units and operator docs.
+
+### C. Omnifail platform first
+
+Build tier engine and SearXNG discovery before compare/vision UI. **Pros:** routing depth first. **Cons:** visitors see no multimodal/compare win until late; higher risk of over-engineering backend.
+
+**Recommendation:** **B** — thin vision/compare UX plus tier framework in one wave; SearXNG discovery included as specified by product owner (not deferred).
+
+## Scope boundaries
+
+**In scope:** `webui/`, optional `edge/` or companion runner docs, operator settings UI, Playwright e2e for vision/compare happy paths (mocked), `docs/CAVEATS.md`, `CONCEPTS.md`.
+
+**Out of scope (this wave):**
+
+- Tool-call / reasoning UI, PWA offline shell, cloud session sync
+- Repo-owned credentials for third-party web UIs
+- Mandatory Playwright on Cloudflare Worker without user opt-in
+- Full TypeScript port of Python model discovery
+
+**Deferred to follow-up work:**
+
+- Automatic account creation on discovered web UIs
+- More than two compare columns
+- Vision through web-UI tier if first ship is text-only for that tier (must fail clearly per R29)
+
+## Success criteria
+
+- User attaches a PNG, picks a vision-capable model, receives a relevant description via proxy or BYOK.
+- Compare mode: same prompt produces two visible streaming columns with distinct source labels.
+- User reorders tiers in settings; next chat respects new order; failure diagnostics list attempted tiers.
+- With SearXNG configured, exhausted API tiers yield at least one discovered candidate URL or an explicit “discovery empty” message — not a generic network error.
+- Public zero-config demo works with tiers 37-only (API/proxy) when web and SearXNG tiers are disabled.
+
+## Key decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| K5 | Include SearXNG discovery in Wave 4B | Product owner expand confirm; aligns with omnifail thesis |
+| K6 | Web-UI automation tier is opt-in / user-run | Static Pages cannot host Playwright; ToS and abuse risk |
+| K7 | Compare binds per-column tier or model | Supports “ChatGPT web vs our free proxy” not only “two proxy models” |
+| K8 | Speculative demand recorded as assumption | No visitor evidence yet; ship for demo parity and routing depth |
+
+## Dependencies and assumptions
+
+- Waves 1–4 merged and deployed on `main`.
+- murm-ui `AttachmentPlugin` supports local image processing; multimodal request shaping may require provider extension (planning).
+- `searxng/search` exists in catalog artifacts but web-UI **discovery** is net-new behavior — assumes user provides SearXNG base URL (self-hosted or public instance).
+- Headless web-UI tier assumes a companion process the user runs locally or on Render — not verified on Worker today.
+
+## Outstanding questions
+
+| ID | Question | Default for planning |
+|----|----------|---------------------|
+| Q4 | Compare layout: split-pane in `#chatMount` vs full-width overlay? | Split-pane in main chat area |
+| Q5 | SearXNG discovery: auto-queue top URL to web tier vs show pick list? | Show pick list; user confirms before automation |
+| Q6 | Max images per message | 1 image for thin slice; raise in follow-up if trivial |
+
+## Research references
+
+- [DEV — image upload in chat (2025)](https://dev.to/newbe36524/implementing-image-upload-and-ai-recognition-in-chat-a-complete-solution-from-design-to-4le0) — attachment bar, preview-before-send, multimodal fallback
+- [AI Chat UI best practices (2026)](https://thefrontkit.com/blogs/ai-chat-ui-best-practices) — document/image previews, streaming stability
+- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (Wave C deferred list)
diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md
new file mode 100644
index 0000000..77cf732
--- /dev/null
+++ b/docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md
@@ -0,0 +1,142 @@
+---
+title: Chat UI Wave 5 — agent UX trust layer
+date: 2026-07-25
+status: confirmed
+priority_wave: wave5-agent-ux
+origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md
+prior_waves: [wave1, wave2, wave3, wave4, wave4b]
+strategy: STRATEGY.md
+---
+
+# Chat UI Wave 5 — agent UX trust layer
+
+## Summary
+
+After Wave 4B (vision, compare, omnifail tiers), close the remaining **2026 agent UX gap** on the static demo: **collapsible reasoning blocks**, **tool-call lifecycle cards**, and **composer voice input** on supported browsers. Optional follow-on slice adds **PWA installability** and offline read of local sessions. Stays static-first; no MCP marketplace, tool execution loop, or backend accounts.
+
+## Problem Frame
+
+Waves 1–4 delivered table-stakes chat (model picker, routing chip, export/import, streaming polish). Wave 4B addresses multimodal and compare. Visitors comparing to ChatGPT and Claude still see a **2023-class agent surface**: murm-ui already receives `reasoning_delta` and `tool_call_*` stream events, but the bundled renderer **skips reasoning** (`continue`) and shows tool calls as a one-line emoji placeholder. ResearchWizard **voice chrome exists but stays hidden** (Wave 1 R18) — which now reads as unfinished rather than intentionally unsupported.
+
+Demand is **speculative** (no observed visitor workaround). Success is measured by demo parity with reasoning-model and agentic-chat expectations, not traffic proof. STRATEGY still anchors static Pages + edge proxies — Wave 5 is client-side trust polish, not a pivot to Open WebUI.
+
+## Requirements
+
+### Reasoning / thinking blocks
+
+| ID | Requirement |
+|----|-------------|
+| R44 | When the active route streams **reasoning content**, assistant messages show a **collapsed thinking header** (e.g. “Thinking…” while streaming, “Thought for Ns” when complete) with expand/collapse for the full trace. |
+| R45 | When the provider **does not** expose a reasoning channel, the UI shows **nothing** — no empty placeholders or fake thinking states. |
+| R46 | The **final answer text** remains visually primary; reasoning uses muted styling and sits above or beside the answer without displacing it. |
+
+### Tool-call display (read-only v1)
+
+| ID | Requirement |
+|----|-------------|
+| R47 | Streamed tool invocations render as **lifecycle cards** with states `pending → running → success | error | cancelled`, replacing the default emoji one-liner. |
+| R48 | Wave 5 is **display-only** — the public demo does not execute tools client-side or round-trip tool results unless a later wave explicitly adds it. |
+| R49 | Each card shows **tool name**, **status**, and an **expandable args summary**; raw JSON is available on expand, not the default view. |
+
+### Voice input
+
+| ID | Requirement |
+|----|-------------|
+| R50 | A **mic control** in the composer uses the **Web Speech API** to dictate into the text field on supported browsers (Chromium-first). |
+| R51 | Unsupported browsers show a **disabled mic** with tooltip copy explaining the limitation — not a hidden or broken button. |
+| R52 | Voice **fills the composer only**; the user reviews and sends manually (no auto-send on end-of-speech in v1). |
+
+### PWA shell (optional Wave 5B slice)
+
+| ID | Requirement |
+|----|-------------|
+| R53 | A **service worker** caches the app shell, static assets, and `free_models.json` for repeat visits. |
+| R54 | When offline, users can **read prior sessions** from IndexedDB; **chat send is disabled** with a clear “offline” message — not a silent failure. |
+| R55 | Service worker updates prompt the user to **reload** before applying, avoiding mid-stream breakage during active chat. |
+
+### Trust and demo constraints
+
+| ID | Requirement |
+|----|-------------|
+| R56 | Reasoning and tool UI **degrade gracefully** on routes that emit text-only SSE — zero-config proxy chat behavior is unchanged. |
+| R57 | Voice and PWA features include **no new server endpoints** on the public Worker; optional BYOK routes behave the same as today. |
+
+## Approaches considered
+
+### A. Agent UX trust layer (recommended)
+
+Ship R44–R52 in one wave: murm-ui plugins + SSE mapping extensions so reasoning and tool events reach the renderer; unhide and wire `#voiceInputBtn`. **Pros:** Highest perception ROI vs carrying cost; reuses existing stream plumbing and shell CSS. **Cons:** Provider-dependent reasoning visibility on free routes.
+
+### B. PWA-first
+
+Prioritize R53–R55 before reasoning/tools/voice. **Pros:** Repeat-visitor retention, installable icon. **Cons:** Does not fix the “feels like 2023 agent chat” comparison; SW build pipeline adds CI surface before visible UX win.
+
+### C. MCP client panel first
+
+Ship external MCP connect + tool list before display polish. **Pros:** Power-user agent gateway story. **Cons:** CORS/auth complexity, conflicts with “ranked free model demo” positioning; high trust surface for a speculative need.
+
+**Recommendation:** **A**, then **B** as a separate PR-sized slice (5B). Defer **C** until product explicitly pivots toward agent gateway. **Extend** existing murm-ui block rendering and `FailoverProvider` SSE mapping — net-new plugins, not a fork.
+
+## Scope boundaries
+
+**In scope:** `webui/`, murm-ui plugin(s), SSE event forwarding, shell voice wiring, optional SW in build pipeline, Playwright e2e with mocks, `docs/chat-ui-plugins.md`, `CONCEPTS.md`.
+
+**Deferred for later:**
+
+- Client-side or Worker-side **tool execution loop**
+- MCP client settings panel and remote tool invocation
+- TTS / read-aloud replies, wake word, MediaRecorder + paid STT
+- Generative UI / iframe widget runtime for tool results
+- More than two compare columns (Wave 4B scope)
+- Vision through web-UI tier (Wave 4B deferral)
+
+**Outside this product's identity (STRATEGY):**
+
+- Open WebUI / LibreChat embedding, user accounts, cloud session sync
+- MCP marketplace, RAG pipelines, org-wide tool registries
+- Full offline AI (local inference without network)
+
+## Success criteria
+
+- On a BYOK or proxy route that streams reasoning deltas, user sees collapsed thinking with working expand/collapse and a visible final answer.
+- On a mocked tool-call stream, user sees lifecycle cards with status transitions — not the emoji placeholder.
+- On Chrome, mic dictation fills `#chatinput`; on Safari/Firefox, mic is disabled with explanatory tooltip.
+- Zero-config text-only chat on the public homepage is unchanged when no reasoning/tools/voice features activate.
+- (5B) With SW registered, repeat visit loads from cache; offline mode shows history read-only and blocks send with clear copy.
+
+## Key decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| K9 | **Display-only tool cards in v1** | Agentic credibility without Worker tool execution or abuse surface |
+| K10 | **Reasoning is provider-gated** | Matches 2026 norm; avoids fake thinking on models without a channel |
+| K11 | **Voice = composer dictation, no TTS** | Reuses hidden shell affordance; legacy `docs/legacy/chatgpt-web/` patterns inform UX, not wholesale port |
+| K12 | **PWA as optional 5B slice** | Installability is medium value; agent UX polish is higher leverage for “not simplistic” perception |
+| K13 | **MCP deferred** | Speculative power-user need; CORS and auth cost disproportionate to demo thesis |
+| K14 | **Speculative demand recorded** | No visitor evidence yet; ship for ChatGPT-class parity after Wave 4B lands |
+
+## Dependencies and assumptions
+
+- **Wave 4B merged** before Wave 5 **implementation** starts — avoids parallel large diffs on `FailoverProvider` and composer. (Planning for Wave 5 may proceed earlier.)
+- murm-ui stream event types for `reasoning_delta` and `tool_call_*` already exist in the bundled renderer; `webui/src/providers/sse.ts` currently forwards **text only** — planning must extend mapping (verified in repo).
+- Free proxy routes may not emit reasoning or tool streams today; BYOK paths are the primary validation target for R44–R49.
+- Web Speech API availability is Chromium-skewed; graceful degrade is mandatory.
+- `docs/manifest.json` exists; no service worker today (verified).
+
+## Outstanding questions
+
+| ID | Question | Default for planning |
+|----|----------|---------------------|
+| Q7 | Reasoning default: collapsed always vs remember user expand preference? | Collapsed always; session-persist expand is follow-up |
+| Q8 | Tool card placement: inline in message vs grouped timeline? | Inline above answer text, matching reasoning block |
+| Q9 | PWA: ship in same release as 5A or separate 5B PR? | Separate 5B PR after 5A merges |
+
+## Research references
+
+- [UI Potion — AI response rendering patterns](https://uipotion.com/potions/patterns/ai-response-rendering) — reasoning gating, tool lifecycle cards
+- [TanStack AI — thinking content](https://tanstack.com/ai/latest/docs/chat/thinking-content) — UI-only reasoning, progressive disclosure
+- [UX/UI Principles — tool-use UX](https://uxuiprinciples.com/en/principles/tool-use-function-calling-ux) — show activity, human-in-the-loop norms
+- [MDN — PWA offline operation](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation) — SW + IndexedDB patterns
+- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (deferred tool/reasoning, PWA)
+- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md` (Wave 4B out-of-scope list)
+- Repo: `docs/assets/chat.js` (murm-ui reasoning/tool render hooks), `docs/legacy/chatgpt-web/` (prior voice UX), `webui/shell/styles.css` (`#voiceInputBtn` styles)
diff --git a/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md b/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md
new file mode 100644
index 0000000..893a0de
--- /dev/null
+++ b/docs/brainstorms/2026-07-25-chat-ui-wave6-transparency-discovery-requirements.md
@@ -0,0 +1,185 @@
+---
+title: Chat UI Wave 6 — transparency, branching, and discovery
+date: 2026-07-25
+status: confirmed
+priority_wave: wave6-transparency-discovery
+origin: user brainstorm — what to improve after Waves 1–5
+prior_waves: [wave1, wave2, wave3, wave4, wave4b, wave5]
+strategy: STRATEGY.md
+---
+
+# Chat UI Wave 6 — transparency, branching, and discovery
+
+## Summary
+
+After Wave 5 (agent UX trust layer), make the static demo **teach and retain** — not just chat. Wave 6 adds **per-reply usage and latency**, a **failover timeline** that explains ranked routing, **conversation branching**, a **prompt template library**, and **read-only share/embed** modes. Optional slice adds a **session analytics drawer** and **read-only artifact pane**. Stays static-first; no accounts, MCP execution, or cloud session sync.
+
+## Problem Frame
+
+Waves 1–5 close the ChatGPT-class baseline: picker, routing chip, export/import, vision/compare, omnifail tiers, reasoning/tools display, voice, and optional PWA. Visitors still leave without understanding **why this project exists** — the ranked `free` alias, fallback hops, and quality scoring are buried in README copy. Meanwhile 2026 chat products treat **branching**, **prompt libraries**, and **usage transparency** as table stakes for power users.
+
+Demand remains **speculative** (no visitor evidence). Success is measured by demo differentiation (failover story visible in-product) and power-user parity, not traffic proof. STRATEGY still anchors Python artifacts + edge proxies — Wave 6 is client-side education and local-session depth, not a pivot to Open WebUI.
+
+## Requirements
+
+### Usage and latency transparency
+
+| ID | Requirement |
+|----|-------------|
+| R58 | Each assistant reply shows a compact **usage badge**: input/output token counts when the route exposes them, plus **time-to-first-token** and total duration. |
+| R59 | When token counts are unavailable, the badge shows **latency only** — no fabricated token estimates. |
+| R60 | A **session totals row** (footer or drawer header) aggregates tokens and wall time for the active session. |
+
+### Failover timeline and routing education
+
+| ID | Requirement |
+|----|-------------|
+| R61 | The routing chip expands into a **failover timeline**: ordered attempts with endpoint, resolved model, outcome (success / skip / error class), and hop index. |
+| R62 | Timeline copy distinguishes **`free` ranked alias** from **`openrouter/free`** meta-router in one sentence accessible from the expanded view. |
+| R63 | When omnifail tiers (Wave 4B) are active, the timeline includes **tier name** and skip reason for disabled or exhausted tiers. |
+| R64 | Timeline data comes from **response metadata already available** to the client; Wave 6 does not add new proxy logging backends. |
+
+### Conversation branching
+
+| ID | Requirement |
+|----|-------------|
+| R65 | Users **branch from any user message**: edit-and-resubmit creates a sibling thread without deleting the original path. |
+| R66 | The sidebar shows a **branch indicator** on sessions with forks; users can switch active branch within a session. |
+| R67 | Export/import (Wave 3) includes branch structure or exports the **active branch only** with clear labeling — planning picks one behavior and documents it. |
+| R68 | Branching is **IndexedDB-only**; no server merge or cross-device sync. |
+
+### Prompt template library
+
+| ID | Requirement |
+|----|-------------|
+| R69 | A **template picker** (slash command or composer menu) offers bundled prompts: compare two models, summarize, debug code, explain ranking — sourced from static repo artifacts. |
+| R70 | Templates support **`{{variable}}` placeholders**; the UI prompts for values before inserting into the composer. |
+| R71 | Users can **save custom templates** to local storage; bundled templates are read-only. |
+| R72 | Templates respect the active model and route capabilities (e.g., vision template disabled when route lacks vision). |
+
+### Share and embed (static-safe)
+
+| ID | Requirement |
+|----|-------------|
+| R73 | **Share snapshot** produces a read-only view of the current session (active branch) via **client-encoded URL** or downloadable HTML — no account required. |
+| R74 | Shared views are **read-only**; recipients cannot send messages or see API keys. |
+| R75 | **`?embed=1`** (or equivalent) renders chromeless chat suitable for iframe embed in README/docs; Turnstile and rate-limit warnings still apply when sending. |
+| R76 | Share payloads **strip secrets** (BYOK keys, guest token values, runner URLs with credentials). |
+
+### Session analytics drawer (optional Wave 6C)
+
+| ID | Requirement |
+|----|-------------|
+| R77 | A slide-out **session analytics** panel shows per-turn model used, fallback rate, error mix, and cumulative latency for the active session. |
+| R78 | Analytics are computed **client-side** from stored messages and routing metadata; no new analytics backend. |
+| R79 | Drawer is **informational** — it does not gate chat or require opt-in beyond opening it. |
+
+### Read-only artifact pane (optional Wave 6C)
+
+| ID | Requirement |
+|----|-------------|
+| R80 | When an assistant message contains a fenced **HTML, SVG, or Mermaid** block, users can open a **sandboxed preview pane** beside the thread. |
+| R81 | Preview is **display-only** — no script execution beyond sandboxed iframe rules; no tool round-trip. |
+| R82 | Artifact pane degrades to **code view only** when content type is unsupported or sandbox blocks render. |
+
+### Mobile shell polish
+
+| ID | Requirement |
+|----|-------------|
+| R83 | Chat layout uses a **mobile-first bottom sheet** for model picker, settings, and analytics on narrow viewports. |
+| R84 | Composer controls (attach, mic, send) sit in the **thumb zone** with minimum 44px touch targets. |
+| R85 | Streaming messages do not trap scroll; **sticky composer** remains visible without obscuring the last reply. |
+
+### Streaming accessibility+
+
+| ID | Requirement |
+|----|-------------|
+| R86 | **`aria-live` announces lifecycle events** (generation started, completed, error) — not per-token updates. |
+| R87 | Keyboard shortcut **re-reads the last assistant message** for screen-reader users. |
+| R88 | Expanded failover timeline and usage badges are **keyboard reachable** with visible focus rings. |
+
+## Approaches considered
+
+### A. Transparency-first (recommended)
+
+Ship R58–R64, R69–R72, and R83–R88 in one wave: make failover and usage visible, add templates and mobile/a11y polish. **Pros:** Highest alignment with STRATEGY messaging; low server cost; compounds routing chip from Wave 1. **Cons:** Less viral than share features alone.
+
+### B. Branching and share-first
+
+Prioritize R65–R76 before usage timeline. **Pros:** Matches ChatGPT Sept 2025 branching; share links aid distribution. **Pros:** Viral/demo growth angle. **Cons:** Does not explain *why* this gateway exists; branch graph adds IndexedDB migration risk before core story is visible.
+
+### C. Analytics and artifacts panel
+
+Prioritize R77–R82 before branching. **Pros:** Power-user delight; artifact pane feels “agentic” without execution loop. **Cons:** Higher UI surface; artifacts less differentiated than failover education on a failover-focused product.
+
+**Recommendation:** **A** as Wave 6A, then **B** (branching + share) as 6B if scope needs splitting. **C** (analytics + artifacts) as optional 6C slice — ship only if 6A/6B land under budget. **Extend** routing chip, session storage, and static template artifacts — no new backend services.
+
+## Scope boundaries
+
+**In scope:** `webui/`, static template JSON/markdown in `docs/` or `webui/`, IndexedDB schema extensions for branches, optional Worker header passthrough for token counts (if not already exposed), Playwright e2e, `CONCEPTS.md`, `docs/chat-ui-plugins.md`.
+
+**Deferred for later:**
+
+- MCP client panel and **tool execution loop** (Wave 5 display-only remains the ceiling until product pivot)
+- TTS, wake word, MediaRecorder + paid STT
+- Full **generative UI widget runtime** (interactive tool results, not sandboxed preview)
+- **Cloud-hosted share** with KV persistence and TTL (v1 share is client-encoded or download)
+- Text/PDF document attach beyond images (high cost, uneven free-model support)
+- Session folders/tags across devices
+- More than two compare columns
+
+**Outside this product's identity (STRATEGY):**
+
+- User accounts, org workspaces, cloud session sync
+- Full TypeScript port of Python discovery or live scoring in the browser
+- Open WebUI / LibreChat embedding
+- Paid analytics backends or third-party product analytics SDKs
+
+## Success criteria
+
+- A first-time visitor expands a reply and sees **which models were tried**, why fallback occurred, and how `free` differs from `openrouter/free`.
+- Usage badge shows real token counts on at least one BYOK/proxy route; latency-only degrade on routes without usage metadata.
+- User branches from a mid-conversation message, switches branch in sidebar, and exports without data loss on the chosen export semantics.
+- Template picker inserts a compare-models prompt with variables filled; custom template persists across reload.
+- Share link or HTML snapshot opens read-only with secrets stripped; `?embed=1` loads in an iframe without layout breakage.
+- Mobile viewport: model picker opens as bottom sheet; composer remains usable one-handed.
+- Screen reader hears start/complete announcements; failover timeline is keyboard navigable.
+
+## Key decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| K15 | **Transparency before branching** | Product thesis is ranked failover — teach it in-product before parity features |
+| K16 | **No fabricated usage data** | Trust beats impressiveness; matches Wave 5 reasoning-gating pattern |
+| K17 | **Share v1 is client-encoded or download** | Avoids KV/auth surface on public Worker; fits static-first |
+| K18 | **Templates from static artifacts** | Preserves Python-as-brain; no live template CMS |
+| K19 | **Artifact pane is sandboxed preview only** | Agentic feel without deferred tool execution |
+| K20 | **6B/6C are splittable** | Keeps 6A shippable if branch graph or analytics overrun estimate |
+| K21 | **Speculative demand recorded** | No visitor evidence; ship for differentiation after Wave 5 |
+
+## Dependencies and assumptions
+
+- **Wave 5 merged** before Wave 6 — avoids parallel composer and SSE mapper conflicts.
+- Wave 4B routing metadata (R6–R7) supplies hop timeline inputs for Wave 6A. Tier names in the timeline (R63) land when the Wave 4B orchestrator exists; do not block 6A on web/SearXNG tiers.
+- Token usage requires provider/proxy to expose counts in response or stream trailers; not all free routes will qualify (R59 degrade path).
+- Branching may require murm-ui session graph support or a thin wrapper — planning verifies without prescribing architecture here.
+- Share URL length limits may cap very large sessions; planning defines max snapshot size or falls back to download-only.
+
+## Outstanding questions
+
+| ID | Question | Default for planning |
+|----|----------|---------------------|
+| Q10 | Branch export: full tree vs active branch only? | Active branch only in v1; tree export follow-up |
+| Q11 | Share: compressed hash URL vs downloadable HTML file? | Hash URL under size cap; HTML download fallback |
+| Q12 | Session analytics in 6A or 6C? | 6C optional slice after 6A |
+| Q13 | Mobile bottom sheet: replace all slide panels or chat-specific only? | Chat-adjacent panels first (model picker, analytics) |
+
+## Research references
+
+- [UX/UI Principles — AI cost transparency](https://uxuiprinciples.com/en/principles/ai-cost-transparency) — usage-at-action-time norms
+- [OpenRouter — reliability failover](https://openrouter.ai/blog/insights/reliability-failover/) — hop transparency patterns
+- [UIPotion — AI response rendering](https://uipotion.com/potions/patterns/ai-response-rendering) — streaming a11y lifecycle
+- [NN/G — bottom sheets](https://www.nngroup.com/articles/bottom-sheet/) — mobile overlay UX
+- [Ars Technica — ChatGPT branching (Sept 2025)](https://arstechnica.com/ai/2025/09/chatgpts-new-branching-feature-is-a-good-reminder-that-ai-chatbots-arent-people/) — branch prior art
+- Prior: `docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md` (deferred MCP, execution)
+- Prior: `docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md` (R10–R12 catalog education)
diff --git a/docs/chat-ui-plugins.md b/docs/chat-ui-plugins.md
index 283e9ca..58e8f23 100644
--- a/docs/chat-ui-plugins.md
+++ b/docs/chat-ui-plugins.md
@@ -8,7 +8,7 @@ The public chat at [`docs/index.html`](../index.html) is built from [`webui/`](.
|-------|--------|------|
| Shell | [ai-researchwizard](https://github.com/bolabaden/ai-researchwizard) (`webui/shell/styles.css`) | Top bar, slide panels, dark theme |
| Chat engine | [murm-ui](https://github.com/levmv/murm-ui) | `ChatUI` + `IndexedDBStorage` |
-| Routing | `FailoverProvider` | Cloud proxy first (SSE), optional browser BYOK fallback |
+| Routing | `FailoverProvider` | Tier orchestrator: direct/BYOK → optional runner/SearXNG → cloud proxy SSE |
## Build
@@ -28,6 +28,9 @@ Set `APP_VERSION` when building for cache busting (CI sets this from `github.sha
|--------|-------|---------|
| `failover-settings` | Server (top bar) | Proxy endpoints, guest token, default model, test connection |
| `byok-settings` | Your keys | Optional provider API keys (`localStorage` only) |
+| `tier-settings` | Tiers | Ordered omnifail route stack (enable/reorder + optional runner/SearXNG URLs) |
+| `compare-mode` | Composer toggle | Two-column model compare (same prompt → dual proxy/BYOK streams) |
+| `discovery-picklist` | Above composer | SearXNG-discovered free chat links (manual open only, dismissible) |
| `model-explorer` | Models | Filter/sort `free_models.json`; **Use for chat** sets session model |
| `model-picker` | Composer | Dropdown for `free`, `openrouter/free`, and top catalog models |
| `routing-chip` | Messages | Endpoint / model / fallback metadata under assistant replies |
@@ -40,6 +43,8 @@ Wave 3 adds **catalog enrichment** (context + capability badges in Models panel
Wave 4 adds **streaming polish** (plain-text tail during SSE, full markdown on completion — `patch-package` on murm-ui), **conversation import** (symmetry with export), **copy session link**, empty-state copy, and the shortcuts sheet.
+Wave 4B adds **provider tiers** (omnifail route stack in the Tiers panel), **image attachments** (composer tray → multimodal proxy requests), **compare mode** (two-column dual streams), **SearXNG discovery** (suggested free chat links when the tier is enabled — never automated), and an **opt-in local web-UI runner** (`runner/` — OpenAI-shaped SSE over user-configured adapters, see `runner/README.md`).
+
## Session export, import, and hash links
- **Export** — Session ⋮ menu → “Export as Markdown” or “Export as JSON”. Serializes text blocks only; empty chats disable the items.
@@ -69,6 +74,7 @@ ResearchWizard includes MCP config UI backed by a server. Static Pages cannot ho
| `llm_fallbacks_guest_token` | Bearer token for proxy auth |
| `llm_fallbacks_default_model` | Default chat model (usually `free`) |
| `llm_fallbacks_api_keys` | Optional BYOK map |
+| `llm_fallbacks_provider_tiers` | Omnifail tier order, enable flags, runner/SearXNG URLs |
| `llm_fallbacks_shortcuts_hint_dismissed` | `1` after user dismisses first-visit shortcuts hint |
Zero-config values seed from `docs/config.js` on first visit (`seedZeroConfigFromPageConfig` in `webui/src/config.ts`).
diff --git a/docs/index.html b/docs/index.html
index 2f40af3..ed29a74 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -24,6 +24,7 @@
Server
Your keys
+
Models
@@ -67,6 +68,7 @@
+
× Close
diff --git a/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md b/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md
index 262fa29..a4c0bba 100644
--- a/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md
+++ b/docs/plans/2026-07-24-006-feat-chat-ui-wave3-catalog-export-plan.md
@@ -1,6 +1,6 @@
---
title: "feat: Chat UI Wave 3 — catalog differentiation, export, hash routing"
-status: active
+status: completed
date: 2026-07-24
type: feat
origin: docs/brainstorms/2026-07-24-chat-ui-improvements-requirements.md
diff --git a/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md b/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md
index 7f98aa0..28439ba 100644
--- a/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md
+++ b/docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md
@@ -270,7 +270,7 @@ U4/U5 parallel with U2/U3 after U1 spike confirms patch path.
### Deferred to Follow-Up Work
-- Wave 4B vision upload / Wave 4C model compare (separate brainstorm if pursued)
+- Wave 4B vision upload / model compare (see `docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md`)
- Import merge into existing session
- Upstream murm-ui PR for incremental markdown (if we ship patch-package locally)
diff --git a/docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md b/docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md
new file mode 100644
index 0000000..b2b02c1
--- /dev/null
+++ b/docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md
@@ -0,0 +1,367 @@
+---
+title: "feat: Chat UI Wave 4B — vision, compare, omnifail tiers"
+status: completed
+date: 2026-07-25
+deepened: 2026-07-25
+type: feat
+origin: docs/brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md
+strategy: STRATEGY.md
+wave: 4b
+requirements: R28-R43
+prior_plan: docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md
+---
+
+# feat: Chat UI Wave 4B — vision, compare, omnifail tiers
+
+> **Origin:** [Wave 4B differentiation brainstorm](../brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md) — vision attachments, two-column compare, user-ordered provider tiers including optional headless web-UI runner and SearXNG discovery.
+
+### Delta Update
+
+- **Landed (all units complete, 2026-07-25):** U1 orchestrator stabilized (disjoint tiers, Vitest storage shim, order-preserving `normalizeTierSettings`, structured R40 diagnostics); U2 vision attachments + multimodal mapper + vision guard; U4 Tiers settings panel; U3 compare mode; U6 SearXNG discovery tier + pick-list plugin; U5 opt-in `runner/` companion (stub + generic-selector adapters, Node ≥23.6 native TS) with `web-ui-tier.ts` browser bridge; U7 Playwright specs (`vision-attach`, `compare-mode`, `tier-settings`) registered in `deploy-pages.yml`, CAVEATS/plugins docs, CONCEPTS Wave 4B terms.
+- **Verification:** webui vitest 68 passing; runner node:test 4 passing; Wave 4B Playwright specs green locally; `npm run build` green.
+- **Commits:** a34dfce (U1) → da223b6 (U2) → 5622e91 (U4) → 6948748 (U3) → d57fa78 (U6) → 9262ecf (U5) → 52354ba (U7).
+
+## Summary
+
+Add **multimodal composer attachments**, **compare mode** (two sources, one prompt), and a **configurable provider-tier stack** with **disjoint route ownership**: `quality_api` (direct/BYOK) → optional headless web UI → SearXNG discovery → `proxy_failover` (Worker/Render endpoints). Zero-config demo enables quality_api + proxy_failover; exotic tiers stay opt-in. Exhaust user-enabled routes before giving up — still **best-effort** free-tier HA (STRATEGY), not “never fail.”
+
+## Problem Frame
+
+Waves 1–4 shipped table-stakes chat UX. Visitors still expect **image input** and **model shopping**. Configurable tiers deepen routing beyond today’s proxy→BYOK path. `messagesToOpenAi` still drops file blocks; compare UI and discovery/runner are not shipped. Partial U1 scaffold exists but must be corrected before feature units.
+
+## Requirements traceability
+
+| ID | Requirement |
+|----|-------------|
+| R28–R31 | Vision attach, non-vision guard, catalog filter, size cap |
+| R32–R35 | Compare mode, column labels/chips, rate-limit warning, exit without history loss |
+| R36–R41 | Ordered tiers, API/web/SearXNG tiers, diagnostics, local runner opt-in |
+| R42–R43 | CAVEATS, graceful degradation |
+
+## Key Technical Decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| KTD1 | Register murm-ui `AttachmentPlugin` with image-only accept + 4MB cap (Q6 default: 1 image) | Reuse murm-ui file blocks; R31 client cap; plugin has no built-in max-count — enforce in handler |
+| KTD2 | New `message-openai.ts` multimodal mapper (text + `image_url` data URLs) | Proxy/LiteLLM accept vision when model supports it |
+| KTD3 | **`TierOrchestrator`** with injected handlers | R36–R40 without rewriting FailoverProvider in one pass |
+| KTD4 | Compare = **`ComparePlugin`** with dual provider delegates, not second `ChatUI` | murm-ui single engine |
+| KTD5 | **Web-UI tier** → user-configured **`runner/`** HTTP service | Static Pages cannot run Playwright; R38/R41 |
+| KTD6 | **SearXNG tier** = browser fetch + **pick list** (Q5) | No Worker SearXNG required for v1 |
+| KTD7 | **Disjoint tiers:** `quality_api` = direct/BYOK only; `proxy_failover` = configured proxy endpoints; **defaults enable both**; web_ui + searxng **disabled** | User choice: no duplicate proxy attempts; zero-config needs proxy enabled |
+| KTD8 | Vision export/import text-only in Wave 4B | YAGNI; document in CAVEATS |
+| KTD9 | Vitest: in-memory `localStorage` mock (or injectable storage seam) for tier tests — no full jsdom required | Unblocks U1; Node has no DOM |
+| KTD10 | `normalizeTierSettings` **preserves user order**; only fills missing known tier IDs | Fixes R36/U4 reorder bug in current scaffold |
+| KTD11 | Surface `TierOrchestratorError.attempts` via status strip / structured route error — do not squash to opaque string only | R40 diagnostics |
+| KTD12 | Single storage key `providerTiers` JSON blob (`tiers`, `webRunnerUrl`, `searxngUrl`) | Matches landed code; plan no longer lists separate URL keys |
+| KTD13 | Bootstrap stays `loadRuntimeConfig()` → `FailoverProvider`; never wire tiers to raw `readRuntimeConfig()` alone | AGENTS pitfall 14 / F001 |
+| KTD14 | Compare does **not** depend on runner; API-tier compare ships before U5 | Visible demo win without companion process |
+
+## High-Level Design
+
+```mermaid
+flowchart TB
+ subgraph ui [webui]
+ Attach[AttachmentPlugin]
+ Compare[ComparePlugin]
+ TierUI[Tier settings panel]
+ Engine[ChatEngine]
+ end
+
+ subgraph orch [TierOrchestrator]
+ API[quality_api BYOK/direct]
+ Web[web_ui optional]
+ Searx[searxng_discovery optional]
+ Proxy[proxy_failover Worker/Render]
+ end
+
+ Runner[runner/ Playwright service]
+ SearxInst[User SearXNG instance]
+
+ Attach --> Engine
+ Compare --> Engine
+ Engine --> orch
+ Web --> Runner
+ Searx --> SearxInst
+```
+
+Disjoint ownership: `quality_api` never calls proxy endpoints; `proxy_failover` never runs BYOK browser routes. Missing config → `TierSkipError`, not a hard fail that aborts the chain early.
+
+## Implementation Units
+
+### U1. Provider tier model + orchestrator (R36–R40, R43) — stabilize WIP
+
+**Goal:** Configurable ordered tiers with diagnostics; disjoint handlers; zero-config path unchanged.
+
+**Requirements:** R36, R37, R40, R43
+
+**Files:**
+- `webui/src/providers/tiers/types.ts`
+- `webui/src/providers/tiers/defaults.ts` — defaults: quality_api + proxy_failover **enabled**; order-preserving normalize
+- `webui/src/providers/tiers/settings.ts`
+- `webui/src/providers/tiers/orchestrator.ts`
+- `webui/src/providers/tiers/orchestrator.test.ts` — localStorage mock; order + skip + diagnostics cases
+- `webui/src/providers/FailoverProvider.ts` — split `streamQualityApiRoute` (BYOK/direct only) vs `streamProxyFailoverRoute` (proxy only); pass structured attempts on failure
+- `webui/src/storage-keys.ts` — `providerTiers` only
+- `webui/src/providers/FailoverProvider.tiers.test.ts` (new) — integration: zero-config hits proxy when BYOK absent
+
+**Approach:**
+1. Tier IDs: `quality_api`, `web_ui`, `searxng_discovery`, `proxy_failover`.
+2. Persist ordered list + enabled flags in one localStorage JSON blob.
+3. Orchestrator tries enabled tiers in stored order; collect `{ tier, error }[]`.
+4. `quality_api` = browser-router / BYOK only; skip when no usable keys for selected model.
+5. `proxy_failover` = dual-endpoint SSE loop only.
+6. Fix Vitest storage before asserting behavior.
+
+**Execution note:** Test-first — red tests for order preserve, disjoint ownership, and zero-config before green refactor.
+
+**Test scenarios:**
+- Default settings → quality_api skipped (no keys) then proxy_failover succeeds (mock) — same outcome as pre-refactor zero-config.
+- Disabled web_ui → never calls runner URL.
+- Custom tier order persisted after save/load (normalize must not reset to `TIER_IDS` catalog order).
+- All tiers fail → `TierOrchestratorError.attempts` length ≥2 with tier ids.
+- `TierSkipError` recorded; chain continues.
+- AbortSignal mid-tier → error rethrown, no further tiers.
+
+**Verification:** `cd webui && npm test` green including orchestrator suite.
+
+---
+
+### U2. Vision attachments + multimodal requests (R28–R31)
+
+**Goal:** Image attach in composer; vision requests through enabled API/proxy tiers.
+
+**Requirements:** R28, R29, R30, R31
+
+**Dependencies:** U1
+
+**Files:**
+- `webui/src/main.ts` — register `AttachmentPlugin`
+- `webui/src/providers/message-openai.ts` (new)
+- `webui/src/providers/message-openai.test.ts` (new)
+- `webui/src/providers/FailoverProvider.ts` — use mapper; vision guard before send
+- `webui/src/plugins/model-picker/index.ts` — prefer `supports_vision` when attachments present
+- `webui/shell/chat-overrides.css` — attachment tray; keep `data-theme` dark overrides intact
+
+**Approach:**
+1. AttachmentPlugin: `acceptedTypes: "image/*"`, `maxFileSize: 4_000_000`, max 1 file via handler.
+2. Map `file` blocks → OpenAI `image_url` data URLs.
+3. Guard: attachments && !vision-capable model → block with clear status (R29).
+4. Suggest vision catalog models when image attached (R30).
+
+**Test scenarios:**
+- PNG block → OpenAI body contains `image_url`.
+- Non-vision model + attachment → guard false / blocked send.
+- Oversize file → clear error; session not corrupted.
+
+**Verification:** vitest + mocked proxy path.
+
+---
+
+### U3. Compare mode UI (R32–R35)
+
+**Goal:** Two-column compare with independent source per column (API/proxy tiers — no runner required).
+
+**Requirements:** R32, R33, R34, R35
+
+**Dependencies:** U1, U2
+
+**Files:**
+- `webui/src/plugins/compare-mode/index.ts` (new)
+- `webui/src/plugins/compare-mode/column-provider.ts` (new)
+- `webui/shell/chat-overrides.css` — `.lf-compare-grid`
+- `webui/src/main.ts` — register plugin
+- `tests/e2e/compare-mode.spec.ts` (new)
+
+**Approach:**
+1. Toggle compare; Q4 default: split-pane in `#chatMount`.
+2. Each column: model + optional tier override (defaults to global order).
+3. Fork prompt to two orchestrator/provider instances; grid rows for pairs.
+4. Pre-send banner when both columns use metered routes (R34).
+5. Exit compare restores single column; compare turns append as two assistant messages with column meta.
+
+**Test scenarios:**
+- Two mocked replies → both columns visible.
+- Exit compare → history preserved.
+- Rate-limit banner when compare enabled (mock).
+
+**Verification:** Playwright compare-mode spec.
+
+---
+
+### U4. Tier settings panel (R36, R41, R42)
+
+**Goal:** User configures tier order, SearXNG URL, web runner URL.
+
+**Requirements:** R36, R41, R42
+
+**Dependencies:** U1 (order-preserving settings)
+
+**Files:**
+- `webui/src/plugins/tier-settings/index.ts` (new)
+- `webui/src/plugins/tier-settings/settings.test.ts` (new)
+- `webui/src/shell-panels.ts`
+- `docs/CAVEATS.md` — web automation + SearXNG ToS; disambiguate “provider tiers” (omnifail stack) vs cloud free-tier limits; bootstrap merge note (AGENTS pitfall 14); vision export text-only (KTD8)
+- `docs/chat-ui-plugins.md`
+
+**Approach:**
+1. Shell panel: reorder + enable toggles.
+2. Fields: SearXNG URL, web runner URL (empty default).
+3. Copy: local runner opt-in (R41); ToS responsibility (R42); do not say “never fail.”
+
+**Test scenarios:**
+- Reorder → localStorage order → orchestrator attempt order matches.
+
+**Verification:** vitest round-trip.
+
+---
+
+### U5. Local web-UI runner service (R38)
+
+**Goal:** Minimal companion process for headless web chat automation tier.
+
+**Requirements:** R38 (partial R40)
+
+**Dependencies:** U1 (ships after U3 for sequencing preference)
+
+**Files:**
+- `runner/README.md` (new)
+- `runner/package.json` (new)
+- `runner/src/server.ts` (new)
+- `runner/src/adapters/` (new) — stub + BYO selectors
+- `webui/src/providers/tiers/web-ui-tier.ts` (new)
+
+**Approach:**
+1. OpenAI-shaped SSE; CORS for localhost + Pages.
+2. Generic selector-driven adapter — not hardcoded ChatGPT.
+3. Stub may return 501 until configured.
+
+**Execution note:** Spike one adapter; stub acceptable for merge if documented.
+
+**Test scenarios:**
+- GET `/health`.
+- Mock adapter fixed SSE text.
+
+**Verification:** `cd runner && npm test`
+
+---
+
+### U6. SearXNG discovery tier (R39)
+
+**Goal:** When higher tiers fail, search for candidate free chat URLs; show pick list.
+
+**Requirements:** R39, R40
+
+**Dependencies:** U1, U4
+
+**Files:**
+- `webui/src/providers/tiers/searxng-discovery-tier.ts` (new)
+- `webui/src/providers/tiers/searxng-discovery-tier.test.ts` (new)
+- `webui/src/plugins/discovery-picklist/index.ts` (new)
+
+**Approach:**
+1. Query user SearXNG JSON API with configurable query.
+2. Heuristic filter; pick list (Q5); user confirms before feeding web runner.
+3. Empty → explicit discovery-empty diagnostic (R40).
+
+**Test scenarios:**
+- Mock JSON → ≥1 URL.
+- Empty → typed message.
+- CORS failure → clear diagnostic (proxy spike deferred).
+
+**Verification:** vitest fixtures.
+
+---
+
+### U7. E2E, docs, build (R28–R43)
+
+**Goal:** CI coverage and operator docs.
+
+**Requirements:** All
+
+**Dependencies:** U2–U6 (U5 stub OK if documented)
+
+**Files:**
+- `tests/e2e/vision-attach.spec.ts` (new)
+- `tests/e2e/compare-mode.spec.ts` (new)
+- `tests/e2e/tier-settings.spec.ts` (new, optional)
+- `.github/workflows/deploy-pages.yml` — register specs
+- `docs/CAVEATS.md`, `docs/chat-ui-plugins.md`
+- `CONCEPTS.md` — Wave 4B terms already present; do **not** re-add Wave 5–6 glossary rows in this unit
+
+**Test scenarios:**
+- Vision PNG + mocked vision model.
+- Compare two mocked endpoints.
+- Zero-config: web+searx disabled, text chat works; keep `MODEL_CHAIN` short on public Worker.
+
+**Verification:** `cd webui && npm test && npm run build`; Playwright Wave 4B specs.
+
+## Sequencing
+
+```mermaid
+flowchart LR
+ U1[U1 tiers stabilize] --> U2[U2 vision]
+ U1 --> U4[U4 settings]
+ U2 --> U3[U3 compare]
+ U4 --> U6[U6 searxng]
+ U3 --> U5[U5 runner]
+ U6 --> U5
+ U3 --> U7[U7 e2e]
+ U5 --> U7
+ U6 --> U7
+```
+
+**Recommended order:** U1 → U2 → U4 → U3 → U6 → U5 → U7
+
+Compare before runner so the demo ships multimodal + side-by-side without requiring a companion process.
+
+## Scope Boundaries
+
+**In scope:** `webui/`, `runner/` companion, docs, Playwright mocks.
+
+**Out of scope:** Worker-hosted Playwright, auto-login to third-party chats, 3+ compare columns, tool/reasoning UI (Wave 5), PWA, cloud sync.
+
+### Deferred to Follow-Up Work
+
+- Vision in export/import JSON
+- Named-site runner adapters as optional plugins
+- SearXNG CORS proxy on Worker if browser fetch blocked
+- Wave 5 agent UX / Wave 6 transparency (queued plans/requirements)
+
+## Risks and Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| LiteLLM/proxy rejects large data URLs | Client resize + 4MB cap |
+| Compare layout vs shell | CSS grid; disable on narrow viewports if needed |
+| SearXNG CORS | Document CORS; optional runner proxy spike |
+| Web automation ToS | Off by default; user-run; CAVEATS |
+| Compare doubles rate limits | R34 pre-send warning |
+| Losing murm-ui streaming patch on bump | See `docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md` |
+| Stale single-endpoint localStorage | Always `loadRuntimeConfig()` after settings save |
+
+## Acceptance Examples
+
+- **AE1.** Attach PNG + vision model → assistant describes image via proxy.
+- **AE2.** Compare `free` vs `openrouter/free` (mocked) → two columns, two replies.
+- **AE3.** web_ui + searxng disabled → zero-config text chat matches pre-4B main.
+- **AE4.** SearXNG configured, API/proxy fail mock → pick list with ≥1 URL or explicit empty.
+- **AE5.** Reorder tiers in settings → next send attempts in new order; failure lists tiers tried.
+
+## Open Questions (implementation-time)
+
+| ID | Question | Plan default |
+|----|----------|--------------|
+| Q4 | Split-pane vs overlay | Split-pane in `#chatMount` |
+| Q5 | Auto-queue vs pick list | Pick list |
+| Q6 | Max images | 1 for v1 |
+| Q7 | SearXNG CORS | Direct fetch first; runner proxy if blocked |
+| Q8 | Vitest storage | In-memory localStorage mock in test file / tiny helper |
+
+## Sources
+
+- [Wave 4B requirements](../brainstorms/2026-07-25-chat-ui-wave4b-differentiation-requirements.md)
+- [STRATEGY.md](../../STRATEGY.md) — best-effort HA; optional companions; no agent-gateway identity
+- murm-ui `AttachmentPlugin` — `webui/node_modules/murm-ui/dist/plugins/attachment/`
+- WIP scaffold — `webui/src/providers/tiers/`, `webui/src/providers/FailoverProvider.ts`
+- Learnings — `docs/solutions/integration-issues/workers-ai-proxy-fallback-and-model-chain.md`, `docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md`
diff --git a/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md b/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md
new file mode 100644
index 0000000..c209654
--- /dev/null
+++ b/docs/plans/2026-07-25-003-feat-chat-ui-wave5-agent-ux-plan.md
@@ -0,0 +1,324 @@
+---
+title: "feat: Chat UI Wave 5 — agent UX trust layer"
+status: queued
+date: 2026-07-25
+type: feat
+origin: docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md
+strategy: STRATEGY.md
+wave: 5
+requirements: R44-R57
+prior_plan: docs/plans/2026-07-25-002-feat-chat-ui-wave4b-differentiation-plan.md
+blocked_by: wave4b
+---
+
+# feat: Chat UI Wave 5 — agent UX trust layer
+
+> **Origin:** [Wave 5 agent UX brainstorm](../brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md) — reasoning blocks, tool-call cards, voice input, optional PWA (5B). **Prerequisite:** Wave 4B merged to `main` before **implementation** starts (planning already complete).
+
+## Summary
+
+Wire **murm-ui's built-in thinking and tools plugins**, extend **FailoverProvider SSE mapping** so reasoning and tool-call stream events reach the renderer, add **composer voice dictation** (Web Speech API), and optionally ship **PWA shell + offline read** as Wave **5B**.
+
+## Problem Frame
+
+murm-ui already handles `reasoning_delta` and `tool_call_*` events and ships `ThinkingPlugin` + `ToolsPlugin`, but the bundled default renderer skips reasoning and shows tool calls as a one-line placeholder. `webui/src/providers/sse.ts` forwards **text only**. Voice chrome exists in ResearchWizard CSS but is hidden by Wave 1 R18 overrides.
+
+## Requirements (Wave 5 traceability)
+
+| ID | Source | Wave 5 requirement |
+|----|--------|-------------------|
+| R44 | Wave 5 | Collapsed thinking header with expand/collapse when reasoning streams |
+| R45 | Wave 5 | No reasoning UI when provider omits channel |
+| R46 | Wave 5 | Final answer visually primary; reasoning muted |
+| R47 | Wave 5 | Tool lifecycle cards replace emoji placeholder |
+| R48 | Wave 5 | Display-only — no tool execution loop |
+| R49 | Wave 5 | Tool name, status, expandable args summary |
+| R50 | Wave 5 | Mic → Web Speech → composer on supported browsers |
+| R51 | Wave 5 | Disabled mic + tooltip on unsupported browsers |
+| R52 | Wave 5 | Manual send after dictation |
+| R53 | Wave 5 | Service worker caches shell + catalog (5B) |
+| R54 | Wave 5 | Offline read sessions; block send with message (5B) |
+| R55 | Wave 5 | SW update reload prompt (5B) |
+| R56 | Wave 5 | Text-only routes unchanged |
+| R57 | Wave 5 | No new Worker endpoints for voice/PWA |
+
+**Out of scope:** MCP client, tool execution loop, TTS, generative UI widgets (see origin doc).
+
+## Key Technical Decisions
+
+| ID | Decision | Rationale |
+|----|----------|-----------|
+| KTD1 | **Reuse murm-ui `ThinkingPlugin` + `ToolsPlugin`** | Already match R44–R49; import via `murm-ui/plugins/thinking` and `murm-ui/plugins/tools` (see origin K9–K10) |
+| KTD2 | **Port OpenAI stream mapping from murm-ui `OpenAIProvider`** into `webui/src/providers/sse.ts` (or shared helper) | Reasoning fields (`reasoning_content`, `reasoning`, `reasoning_text`) and `delta.tool_calls` handling already implemented upstream — avoid reinventing (see origin dependency note) |
+| KTD3 | **Duration label v1: accept murm-ui "Thought Process" / "Thinking…"** | murm-ui toggle does not emit elapsed seconds; "Thought for Ns" deferred to follow-up unless trivial timer added in thin wrapper plugin |
+| KTD4 | **Voice via first-party `voice-input` plugin** | Inject mic into `.mur-form-footer-right`; remove `#voiceInputBtn` from hide list in `chat-overrides.css`; do not port legacy TTS from `docs/legacy/chatgpt-web/` |
+| KTD5 | **5A and 5B as separate PRs** | Origin Q9 default — agent UX ships first; PWA adds build/CI surface |
+| KTD6 | **Branch from `main` after Wave 4B merge** | Avoid parallel diffs on `FailoverProvider` / composer |
+| KTD7 | **Validate reasoning/tools on mocked SSE + BYOK path** | Free proxy may not emit reasoning today; e2e uses fixture streams |
+
+## High-Level Technical Design
+
+```mermaid
+flowchart TB
+ subgraph sse [Stream path 5A]
+ Proxy[FailoverProvider SSE]
+ Map[sse.ts OpenAI mapper]
+ Events[reasoning_delta / tool_call_* / text_delta]
+ Engine[murm-ui ChatEngine]
+ Proxy --> Map --> Events --> Engine
+ end
+
+ subgraph render [Render path 5A]
+ TP[ThinkingPlugin]
+ ToolsP[ToolsPlugin]
+ Engine --> TP
+ Engine --> ToolsP
+ end
+
+ subgraph voice [Voice 5A]
+ Mic[voice-input plugin]
+ WS[Web Speech API]
+ Input[#chatinput]
+ Mic --> WS --> Input
+ end
+
+ subgraph pwa [PWA 5B]
+ SW[service-worker.js]
+ Cache[app shell + free_models.json]
+ SW --> Cache
+ end
+```
+
+## Implementation Units
+
+### U1. OpenAI SSE mapper — reasoning + tool events (R44–R49, R56)
+
+**Goal:** `emitOpenAiSseAsStreamEvents` emits the same event types murm-ui's `OpenAIProvider` emits for reasoning and tool calls.
+
+**Requirements:** R44–R49, R56
+
+**Dependencies:** None (Wave 4B merged on branch)
+
+**Files:**
+- `webui/src/providers/sse.ts` — extend delta handling
+- `webui/src/providers/openai-stream-mapper.ts` (new, optional extract) — mirror murm-ui reasoning/tool logic for testability
+- `webui/src/providers/sse.test.ts` (new)
+
+**Approach:**
+1. Add reasoning extraction for `reasoning_content`, `reasoning`, `reasoning_text`, encrypted variants (match murm-ui `extractReasoning`).
+2. Track `currentReasoningBlockId`, `activeToolCalls` map by index; emit `reasoning_delta`, `tool_call_start`, `tool_call_delta`.
+3. On `finish_reason: tool_calls`, emit `finish` with `tool_use` (existing).
+4. Mark tool blocks `complete` on stream end if murm-ui reducer expects it — verify against `stream-reducer.js` (may need `tool_call_delta` with `status: "complete"` on finish).
+5. Text-only chunks behave exactly as today.
+
+**Patterns to follow:** `webui/node_modules/murm-ui/dist/core/providers/openai.js` (reference only — copy logic into first-party module, do not import from node_modules at runtime).
+
+**Test scenarios:**
+- Chunk with `delta.reasoning_content` → `reasoning_delta` with blockId.
+- Chunk with `delta.content` only → `text_delta` only; no reasoning events.
+- First `tool_calls` chunk with `id` → `tool_call_start`; follow-up chunks → `tool_call_delta` with args append.
+- `[DONE]` after mixed stream → single `finish`.
+- Malformed JSON line → ignored (existing behavior).
+
+**Verification:** `cd webui && npm test` — `sse.test.ts` passes; manual BYOK stream with reasoning model optional.
+
+---
+
+### U2. Register thinking + tools plugins (R44–R49, R46)
+
+**Goal:** Reasoning and tool blocks render via murm-ui plugins instead of default placeholder/skip.
+
+**Requirements:** R44–R49, R46
+
+**Dependencies:** U1
+
+**Files:**
+- `webui/src/main.ts` — register plugins in `plugins:` array **before** message rendering order matters (thinking/tools early in list)
+- `webui/shell/chat-overrides.css` — optional dark-theme tweaks for `.mur-think-wrapper`, `.mur-tool-summary` on embedded shell
+
+**Approach:**
+1. `import { ThinkingPlugin } from "murm-ui/plugins/thinking"` and `ToolsPlugin` from `murm-ui/plugins/tools` (CSS loads via sideEffects).
+2. Register `ThinkingPlugin()` and `ToolsPlugin({ defaultExpanded: false })`.
+3. Confirm text blocks still render via default path when no reasoning/tools present (R56 regression).
+4. Optional: add `lf-think-muted` override so reasoning sits visually subordinate to answer (R46).
+
+**Patterns to follow:** Existing plugin registration in `webui/src/main.ts` (`CopyPlugin`, `RoutingChipPlugin`).
+
+**Test scenarios:**
+- Engine state with `reasoning` block → plugin renders toggle (manual or e2e in U5).
+- Engine state with `tool_call` block status `streaming` → card shows `...` status symbol per ToolsPlugin.
+
+**Verification:** Manual chat with mocked provider emitting reasoning/tool events; zero-config text chat unchanged.
+
+---
+
+### U3. Voice input plugin (R50–R52, R51)
+
+**Goal:** Mic button dictating into composer on Chromium; graceful degrade elsewhere.
+
+**Requirements:** R50–R52, R51, R57
+
+**Dependencies:** None (parallel with U1–U2)
+
+**Files:**
+- `webui/src/plugins/voice-input/index.ts` (new)
+- `webui/src/plugins/voice-input/speech.ts` (new) — feature detect, `SpeechRecognition` / `webkitSpeechRecognition`
+- `webui/src/plugins/voice-input/speech.test.ts` (new)
+- `webui/shell/chat-overrides.css` — remove `#voiceInputBtn` from hide rule; add `.lf-voice-btn` styles aligned with shell
+- `webui/src/main.ts` — register `VoiceInputPlugin()`
+
+**Approach:**
+1. On `onMount`, locate `.mur-form-footer-right` and insert mic button before send.
+2. If `SpeechRecognition` unavailable: render disabled button with `title` explaining browser limitation (R51).
+3. On click: toggle listening; append/interim-update `#chatinput` value; visual `.listening` state (reuse shell CSS patterns).
+4. Do **not** auto-submit on `onend` (R52).
+5. Handle permission denied with status-strip toast.
+
+**Patterns to follow:** Interim results pattern from `docs/legacy/chatgpt-web/` (reference only); `ShortcutsSheetPlugin` for modal/toast patterns.
+
+**Test scenarios:**
+- Mock `SpeechRecognition` → transcript appended to textarea value.
+- No API → button disabled, `title` contains "not supported".
+- `onend` fires → form not submitted.
+
+**Verification:** Manual on Chrome; vitest for feature-detect helpers.
+
+---
+
+### U4. Offline send guard (R54 partial, prep for 5B)
+
+**Goal:** When `navigator.onLine === false`, block send with clear copy even before SW lands (cheap win for 5B).
+
+**Requirements:** R54 (message half)
+
+**Dependencies:** None
+
+**Files:**
+- `webui/src/plugins/offline-guard/index.ts` (new) or hook in `VoiceInputPlugin`'s sibling
+- `webui/src/main.ts` — register plugin
+
+**Approach:**
+1. Listen to `online` / `offline` events.
+2. Disable send button + show banner in status strip or above composer when offline.
+3. IndexedDB sessions remain readable (murm-ui default — no change).
+
+**Test scenarios:**
+- Fire `offline` event → send disabled, message visible.
+- Fire `online` → send re-enabled.
+
+**Verification:** Manual devtools offline; optional vitest event dispatch.
+
+---
+
+### U5. Service worker + cache (R53, R55) — Wave 5B
+
+**Goal:** Repeat visits load cached shell; updates prompt reload.
+
+**Requirements:** R53, R55
+
+**Dependencies:** U1–U4 merged (5A)
+
+**Files:**
+- `webui/public/sw.js` or `webui/src/sw.ts` (new) — compiled/copied to `docs/sw.js`
+- `webui/esbuild.config.mjs` — copy SW to `docs/`
+- `webui/src/pwa/register.ts` (new) — register SW, listen for `controllerchange` / updatefound
+- `webui/src/main.ts` — call register on bootstrap
+- `docs/manifest.json` — verify `start_url`, icons (existing)
+
+**Approach:**
+1. Cache-first for `./assets/**`, `./assets/shell/**`, `./config.js`, `./free_models.json` with versioned cache name (`lf-cache-v${APP_VERSION}`).
+2. Network-first for chat API (N/A — client calls external proxy; no change).
+3. On waiting worker → non-blocking toast "Update available — Reload" (R55).
+4. Do not cache `/v1/chat/completions` or proxy URLs.
+
+**Patterns to follow:** MDN PWA offline guide (origin research); version bump via existing `APP_VERSION` in CI.
+
+**Test scenarios:**
+- Second load serves shell from SW (manual Application tab).
+- New SW waiting → reload prompt appears.
+
+**Verification:** Manual; document in `docs/chat-ui-plugins.md`.
+
+---
+
+### U6. E2E, docs, build (R44–R57)
+
+**Goal:** CI coverage and operator docs for Wave 5A (+ 5B when included).
+
+**Requirements:** All applicable
+
+**Dependencies:** U1–U5 (U5 only if shipping 5B in same release cycle)
+
+**Files:**
+- `tests/e2e/reasoning-block.spec.ts` (new) — mock SSE with reasoning chunks
+- `tests/e2e/tool-call-card.spec.ts` (new) — mock SSE with tool_calls
+- `tests/e2e/voice-input.spec.ts` (new) — disabled state or mocked recognition
+- `docs/chat-ui-plugins.md` — document thinking, tools, voice, PWA
+- `.github/workflows/deploy-pages.yml` — add e2e specs if stable
+
+**Test scenarios:**
+- Mock stream with reasoning → `.mur-think-toggle` visible in assistant message.
+- Mock stream with tool_calls → `.mur-tool-summary` visible (not emoji placeholder).
+- Zero-config text reply → no thinking/tool chrome (regression via existing `pages-chat-zero-config.spec.ts`).
+- Voice: mic present; on Playwright default (Chromium) optionally skip live mic.
+
+**Verification:** `cd webui && npm test && npm run build`; Playwright wave 5 specs.
+
+---
+
+## Sequencing
+
+```mermaid
+flowchart LR
+ U1[U1 SSE mapper] --> U2[U2 plugins]
+ U3[U3 voice] --> U6[U6 e2e docs]
+ U4[U4 offline guard] --> U6
+ U2 --> U6
+ U6 --> U5[U5 PWA 5B]
+```
+
+**Recommended order (5A):** U1 → U2 → U3 → U4 → U6
+
+**5B follow-on:** U5 after 5A merges (can reuse U4 offline guard).
+
+## Scope Boundaries
+
+**In scope:** `webui/`, `tests/e2e/`, `docs/chat-ui-plugins.md`, `docs/sw.js` (5B).
+
+**Out of scope:** Tool execution, MCP panel, TTS, Worker changes.
+
+### Deferred to Follow-Up Work
+
+- "Thought for Ns" elapsed timer (KTD3)
+- Tool execution loop + `tool_result` round-trip
+- Background Sync offline send queue
+- SearXNG / tier work (Wave 4B plan)
+
+**Outside product identity:** MCP marketplace, Open WebUI embedding (STRATEGY).
+
+## Risks and Dependencies
+
+| Risk | Mitigation |
+|------|------------|
+| Free proxy never emits reasoning | Document BYOK/demo path; e2e uses mocks |
+| murm-ui plugin CSS clashes with shell | `chat-overrides.css` spot fixes |
+| Web Speech blocked or denied | R51 disabled state + toast |
+| SW breaks cache busting | Version cache key from `APP_VERSION`; skip caching `chat.js` with query param or use stale-while-revalidate |
+| Wave 4B not merged | KTD6 — rebase after 4B |
+
+## Acceptance Examples
+
+- **AE1.** Mocked reasoning stream → collapsed thinking toggle expands to show trace; answer text visible below.
+- **AE2.** Mocked tool stream → lifecycle card with name and status; expand shows args JSON.
+- **AE3.** Chrome → mic fills composer; user clicks send manually.
+- **AE4.** Safari → mic disabled with tooltip (or skip if e2e uses unsupported flag).
+- **AE5.** Zero-config text chat on homepage → identical to pre-Wave-5 behavior.
+- **AE6.** (5B) Offline → prior messages readable; send blocked with message.
+
+## Sources and Research
+
+- Origin: `docs/brainstorms/2026-07-25-chat-ui-wave5-agent-ux-requirements.md`
+- murm-ui reference: `OpenAIProvider` stream mapping, `ThinkingPlugin`, `ToolsPlugin` (v0.2.0)
+- Prior voice UX: `docs/legacy/chatgpt-web/` (STT patterns only)
+- [TanStack AI thinking content](https://tanstack.com/ai/latest/docs/chat/thinking-content)
+- [MDN PWA offline guide](https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Guides/Offline_and_background_operation)
diff --git a/docs/solutions/README.md b/docs/solutions/README.md
index e9615e4..d9cef4c 100644
--- a/docs/solutions/README.md
+++ b/docs/solutions/README.md
@@ -10,6 +10,7 @@ Incident and workflow notes with YAML frontmatter. Search by `applies_when`, `ca
| **workflow-issues** | [Product pulse without analytics](workflow-issues/ci-based-product-pulse-without-analytics.md) | STRATEGY metrics when PostHog is not wired |
| **integration-issues** | [Workers AI proxy fallback and model chain](integration-issues/workers-ai-proxy-fallback-and-model-chain.md) | 502 on stream fallback, long MODEL_CHAIN |
| **ui-bugs** | [murm-ui light theme on dark shell](ui-bugs/murm-ui-light-theme-on-dark-shell.md) | White chat surfaces on dark Pages shell |
+| **tooling-decisions** | [murm-ui streaming plaintext tail patch](tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md) | SSE flicker; murm-ui bump vs patch-package |
## `applies_when` quick reference
@@ -19,6 +20,7 @@ Incident and workflow notes with YAML frontmatter. Search by `applies_when`, `ca
| ci-based-product-pulse-without-analytics | Need product health recap without PostHog |
| workers-ai-proxy-fallback-and-model-chain | Live chat 502 after OpenRouter 429; MODEL_CHAIN too long |
| murm-ui-light-theme-on-dark-shell | Chat UI renders light surfaces despite dark shell |
+| murm-ui-streaming-plaintext-tail-patch | Stream flicker; re-justify patch after murm-ui upgrade |
## Related
diff --git a/docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md b/docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md
new file mode 100644
index 0000000..0b82e20
--- /dev/null
+++ b/docs/solutions/tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md
@@ -0,0 +1,61 @@
+---
+title: murm-ui streaming plaintext tail via patch-package
+date: 2026-07-25
+category: tooling-decisions
+module: webui
+problem_type: tooling_decision
+component: tooling
+severity: medium
+applies_when:
+ - "Long SSE replies flicker or re-layout during markdown streaming in murm-ui"
+ - "Considering a murm-ui bump, fork, or CSS-only fix for stream render jank"
+ - "Re-evaluating webui/patches/murm-ui+0.2.0.patch after a murm-ui upgrade"
+tags:
+ - murm-ui
+ - patch-package
+ - streaming
+ - sse
+ - markdown
+related_components:
+ - documentation
+---
+
+# murm-ui streaming plaintext tail via patch-package
+
+## Context
+
+Wave 4 closed the remaining “feels basic” gap on long streamed replies. murm-ui’s `MessageNode` throttled then ran full `marked.parse` + `syncDOMChildren` on every tick, so closed code fences and tables re-laid out mid-stream. Upstream bump alone did not fix it; forking murm-ui would own a full chat engine; CSS containment alone could not stop full-block reparse.
+
+## Guidance
+
+Prefer a **bounded `patch-package` change** on murm-ui’s message renderer:
+
+1. While a text block is still generating, render plain text into a `.mur-streaming-text` node (`textContent`) — no markdown parse per token.
+2. When generation completes, clear the plain node and run `applyMarkdown` once for the final content.
+3. Keep the patch under `webui/patches/` and apply it via `postinstall: patch-package` in `webui/package.json`.
+4. Style `.mur-streaming-text` in `webui/shell/chat-overrides.css` so the streaming tail matches the dark shell.
+
+Do **not** fork murm-ui for this class of fix. Revisit the patch on every murm-ui bump: try an upstream fix first; if absent, refresh the patch against the new package version.
+
+## Why This Matters
+
+Full markdown reparse during SSE is the dominant source of streaming flicker on the GitHub Pages demo. A local patch is cheap to maintain, survives CI `npm ci`, and avoids coupling the product to a murm-ui fork. Losing the patch on an unpatched bump silently regresses Wave 4 acceptance (R19–R20).
+
+## When to Apply
+
+- Long assistant streams show full-message flash or re-highlight closed fences mid-stream.
+- Evaluating murm-ui upgrades after Wave 4 shipped.
+- Choosing between CSS-only mitigation, incremental markdown libraries, and dependency patches.
+
+## Examples
+
+**Before (upstream):** throttle timer → `applyMarkdown` on full block text every ~70ms while `generating`.
+
+**After (patch):** during generate, `plainEl.textContent = block.text`; on complete, `applyMarkdown` once. Playwright `tests/e2e/streaming-polish.spec.ts` asserts plain-text stream then final markdown/code.
+
+## Related
+
+- [murm-ui light theme on dark shell](../ui-bugs/murm-ui-light-theme-on-dark-shell.md) — complementary embed pitfalls (`data-theme`, overrides)
+- Plan: `docs/plans/2026-07-25-001-feat-chat-ui-wave4-polish-plan.md` (KTD1/KTD2)
+- Patch: `webui/patches/murm-ui+0.2.0.patch`
+- Operator note: `docs/chat-ui-plugins.md` (Wave 4 streaming polish one-liner)
diff --git a/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md b/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md
index 4ae38ff..7a183af 100644
--- a/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md
+++ b/docs/solutions/ui-bugs/murm-ui-light-theme-on-dark-shell.md
@@ -60,3 +60,4 @@ murm-ui defaults to light theme on `.mur-app` unless `data-theme=dark` is set. E
## Related Issues
- `webui/shell/chat-overrides.css` — canonical override file (copied to `docs/assets/shell/` on build)
+- [murm-ui streaming plaintext tail via patch-package](../tooling-decisions/murm-ui-streaming-plaintext-tail-patch.md) — SSE render jank (different root cause)
diff --git a/runner/README.md b/runner/README.md
new file mode 100644
index 0000000..46df676
--- /dev/null
+++ b/runner/README.md
@@ -0,0 +1,72 @@
+# llm-fallbacks web-UI runner
+
+Opt-in local companion for the chat demo's **web_ui** provider tier (Wave 4B, R38). It exposes an OpenAI-shaped SSE endpoint that the browser client streams from; behind it, a user-configured adapter automates a free web chat UI. The public GitHub Pages demo works fully without this process — zero-config chat always goes through the proxy tier.
+
+## Quick start
+
+Requires **Node.js ≥ 23.6** (runs TypeScript natively via type stripping — no build step).
+
+```bash
+cd runner
+npm install
+npm test # health + SSE contract tests (stub adapter)
+npm start # http://127.0.0.1:8815 — chat returns 501 until configured
+```
+
+Then in the chat demo: **Tiers panel → enable "Local web UI" → set runner URL** to `http://127.0.0.1:8815`.
+
+## Endpoints
+
+| Endpoint | Behavior |
+|----------|----------|
+| `GET /health` | `{ "ok": true, "adapter": "stub" \| "generic-selector" \| null }` |
+| `POST /v1/chat/completions` | OpenAI-style SSE stream; **501** until an adapter is configured |
+
+CORS allows `localhost` / `127.0.0.1` (any port) and `https://*.github.io` origins.
+
+## Configuration
+
+Create `runner/runner.config.json` (or point `RUNNER_CONFIG` at a path):
+
+```json
+{
+ "adapter": "stub",
+ "stubReply": "Hello from the runner",
+ "port": 8815
+}
+```
+
+The stub adapter streams a fixed reply — use it to verify tier wiring end to end.
+
+### Generic selector adapter (BYO selectors)
+
+Automates an arbitrary web chat page with CSS selectors you supply. Requires Playwright:
+
+```bash
+npm i playwright && npx playwright install chromium
+```
+
+```json
+{
+ "adapter": "generic-selector",
+ "port": 8815,
+ "selector": {
+ "targetUrl": "https://example-free-chat.example",
+ "inputSelector": "textarea",
+ "submitSelector": "button[type=submit]",
+ "replySelector": ".assistant-message",
+ "firstReplyTimeoutMs": 30000,
+ "settleMs": 2500,
+ "headless": true
+ }
+}
+```
+
+The adapter navigates to `targetUrl`, types the latest user prompt, clicks submit, and streams the last `replySelector` element's text until it stops growing for `settleMs`.
+
+## Caveats and responsibilities
+
+- **You run this; you own it.** The runner is never hosted by the project and no site selectors are shipped. Automating a third-party chat UI is subject to **that site's terms of service** — review them before pointing the adapter anywhere.
+- No login automation. Sites requiring auth or captchas need `"headless": false` and a manual session, and may still break.
+- One request at a time per adapter; a fresh browser is launched per request (slow but stateless). This is a spike-quality tier, not the primary route — quality API and proxy tiers remain the supported paths.
+- The runner binds to `127.0.0.1` only.
diff --git a/runner/package-lock.json b/runner/package-lock.json
new file mode 100644
index 0000000..d9ef258
--- /dev/null
+++ b/runner/package-lock.json
@@ -0,0 +1,50 @@
+{
+ "name": "llm-fallbacks-runner",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "llm-fallbacks-runner",
+ "version": "0.1.0",
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ },
+ "engines": {
+ "node": ">=23.6"
+ }
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ }
+ }
+}
diff --git a/runner/package.json b/runner/package.json
new file mode 100644
index 0000000..6364661
--- /dev/null
+++ b/runner/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "llm-fallbacks-runner",
+ "version": "0.1.0",
+ "private": true,
+ "description": "Opt-in local companion for the llm-fallbacks web_ui tier: OpenAI-shaped SSE over user-configured web chat adapters.",
+ "type": "module",
+ "engines": {
+ "node": ">=23.6"
+ },
+ "scripts": {
+ "start": "node src/server.ts",
+ "test": "node --test src/server.test.ts"
+ },
+ "devDependencies": {
+ "@types/node": "^22.10.2",
+ "typescript": "^5.7.2"
+ }
+}
diff --git a/runner/src/adapters/generic-selector.ts b/runner/src/adapters/generic-selector.ts
new file mode 100644
index 0000000..f9e41a0
--- /dev/null
+++ b/runner/src/adapters/generic-selector.ts
@@ -0,0 +1,98 @@
+import type { ChatMessage, RunnerAdapter } from "./types.ts";
+import { lastUserPrompt } from "./types.ts";
+
+/**
+ * Generic selector-driven browser adapter (BYO selectors, R38). Automates an
+ * arbitrary free web chat UI: navigate, type the prompt, submit, then poll
+ * the reply element until its text stops growing. Nothing is hardcoded to a
+ * specific site — the user supplies the target URL and CSS selectors and is
+ * responsible for that site's terms of service.
+ *
+ * Playwright is imported lazily so the runner works (stub adapter, /health)
+ * without it installed. Enable with: npm i playwright && npx playwright
+ * install chromium
+ */
+export interface SelectorAdapterConfig {
+ targetUrl: string;
+ inputSelector: string;
+ submitSelector: string;
+ replySelector: string;
+ /** Max ms to wait for the first reply text. Default 30000. */
+ firstReplyTimeoutMs?: number;
+ /** Reply is considered complete after this many ms without growth. Default 2500. */
+ settleMs?: number;
+ /** Run the browser headed for manual login/captcha flows. Default true (headless). */
+ headless?: boolean;
+}
+
+export function createGenericSelectorAdapter(config: SelectorAdapterConfig): RunnerAdapter {
+ for (const key of ["targetUrl", "inputSelector", "submitSelector", "replySelector"] as const) {
+ if (!config[key]) {
+ throw new Error(`generic-selector adapter config is missing "${key}"`);
+ }
+ }
+
+ return {
+ name: "generic-selector",
+ async streamReply(
+ messages: ChatMessage[],
+ onDelta: (text: string) => void,
+ signal?: AbortSignal
+ ): Promise {
+ let playwright: typeof import("playwright");
+ try {
+ playwright = await import("playwright");
+ } catch {
+ throw new Error(
+ "Playwright is not installed. Run: npm i playwright && npx playwright install chromium"
+ );
+ }
+
+ const prompt = lastUserPrompt(messages);
+ const firstReplyTimeoutMs = config.firstReplyTimeoutMs ?? 30_000;
+ const settleMs = config.settleMs ?? 2_500;
+
+ const browser = await playwright.chromium.launch({ headless: config.headless ?? true });
+ try {
+ const page = await browser.newPage();
+ await page.goto(config.targetUrl, { waitUntil: "domcontentloaded" });
+ await page.fill(config.inputSelector, prompt);
+ await page.click(config.submitSelector);
+
+ // Stream growth of the last reply element's text; finish when it
+ // settles or the abort signal fires.
+ let emitted = "";
+ let lastGrowth = Date.now();
+ const deadline = Date.now() + firstReplyTimeoutMs;
+ for (;;) {
+ if (signal?.aborted) return;
+ const text =
+ (await page
+ .locator(config.replySelector)
+ .last()
+ .textContent()
+ .catch(() => null)) ?? "";
+ if (text.length > emitted.length && text.startsWith(emitted)) {
+ onDelta(text.slice(emitted.length));
+ emitted = text;
+ lastGrowth = Date.now();
+ } else if (text && text !== emitted && !text.startsWith(emitted)) {
+ // Reply element re-rendered from scratch; re-emit the full text.
+ onDelta(text);
+ emitted = text;
+ lastGrowth = Date.now();
+ }
+ if (emitted && Date.now() - lastGrowth >= settleMs) break;
+ if (!emitted && Date.now() > deadline) {
+ throw new Error(
+ `No reply text appeared in ${config.replySelector} within ${firstReplyTimeoutMs}ms`
+ );
+ }
+ await page.waitForTimeout(250);
+ }
+ } finally {
+ await browser.close();
+ }
+ },
+ };
+}
diff --git a/runner/src/adapters/stub.ts b/runner/src/adapters/stub.ts
new file mode 100644
index 0000000..4931b25
--- /dev/null
+++ b/runner/src/adapters/stub.ts
@@ -0,0 +1,20 @@
+import type { ChatMessage, RunnerAdapter } from "./types.ts";
+import { lastUserPrompt } from "./types.ts";
+
+/**
+ * Smoke-test adapter: streams a fixed reply in small chunks. Useful for
+ * verifying the tier wiring end to end before configuring a real browser
+ * adapter.
+ */
+export function createStubAdapter(reply?: string): RunnerAdapter {
+ return {
+ name: "stub",
+ async streamReply(messages: ChatMessage[], onDelta: (text: string) => void): Promise {
+ const text = reply ?? `Stub runner echo: ${lastUserPrompt(messages)}`;
+ const words = text.split(/(\s+)/);
+ for (const word of words) {
+ if (word) onDelta(word);
+ }
+ },
+ };
+}
diff --git a/runner/src/adapters/types.ts b/runner/src/adapters/types.ts
new file mode 100644
index 0000000..46797af
--- /dev/null
+++ b/runner/src/adapters/types.ts
@@ -0,0 +1,25 @@
+export interface ChatMessage {
+ role: string;
+ content: string;
+}
+
+/**
+ * A runner adapter turns a chat transcript into a streamed plain-text reply.
+ * Adapters are user-configured — the runner ships with a stub for smoke
+ * testing and a generic selector-driven browser adapter (BYO selectors).
+ */
+export interface RunnerAdapter {
+ name: string;
+ streamReply(
+ messages: ChatMessage[],
+ onDelta: (text: string) => void,
+ signal?: AbortSignal
+ ): Promise;
+}
+
+export function lastUserPrompt(messages: ChatMessage[]): string {
+ for (let i = messages.length - 1; i >= 0; i -= 1) {
+ if (messages[i].role === "user") return messages[i].content;
+ }
+ return "";
+}
diff --git a/runner/src/server.test.ts b/runner/src/server.test.ts
new file mode 100644
index 0000000..2e09e53
--- /dev/null
+++ b/runner/src/server.test.ts
@@ -0,0 +1,90 @@
+import assert from "node:assert/strict";
+import { after, describe, it } from "node:test";
+import type { AddressInfo } from "node:net";
+import type { Server } from "node:http";
+import { createStubAdapter } from "./adapters/stub.ts";
+import { createRunnerServer } from "./server.ts";
+
+const servers: Server[] = [];
+
+function listen(server: Server): Promise {
+ servers.push(server);
+ return new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => {
+ const { port } = server.address() as AddressInfo;
+ resolve(`http://127.0.0.1:${port}`);
+ });
+ });
+}
+
+after(() => {
+ for (const server of servers) server.close();
+});
+
+describe("runner server", () => {
+ it("GET /health reports the active adapter", async () => {
+ const base = await listen(createRunnerServer(createStubAdapter()));
+ const res = await fetch(`${base}/health`);
+ assert.equal(res.status, 200);
+ assert.deepEqual(await res.json(), { ok: true, adapter: "stub" });
+ });
+
+ it("streams OpenAI-shaped SSE from the stub adapter", async () => {
+ const base = await listen(createRunnerServer(createStubAdapter("fixed runner reply")));
+ const res = await fetch(`${base}/v1/chat/completions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: "web-ui",
+ messages: [{ role: "user", content: "hello" }],
+ stream: true,
+ }),
+ });
+ assert.equal(res.status, 200);
+ assert.match(res.headers.get("content-type") ?? "", /text\/event-stream/);
+
+ const raw = await res.text();
+ let text = "";
+ let finished = false;
+ for (const line of raw.split("\n")) {
+ if (!line.startsWith("data:")) continue;
+ const data = line.slice(5).trim();
+ if (data === "[DONE]") continue;
+ const parsed = JSON.parse(data) as {
+ choices?: { delta?: { content?: string }; finish_reason?: string }[];
+ };
+ text += parsed.choices?.[0]?.delta?.content ?? "";
+ if (parsed.choices?.[0]?.finish_reason === "stop") finished = true;
+ }
+ assert.equal(text, "fixed runner reply");
+ assert.ok(finished, "expected a finish_reason=stop chunk");
+ assert.ok(raw.includes("data: [DONE]"));
+ });
+
+ it("returns 501 with guidance when no adapter is configured", async () => {
+ const base = await listen(createRunnerServer(null));
+ const res = await fetch(`${base}/v1/chat/completions`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ messages: [{ role: "user", content: "hello" }] }),
+ });
+ assert.equal(res.status, 501);
+ const body = (await res.json()) as { error: { message: string } };
+ assert.match(body.error.message, /runner\.config\.json/);
+ });
+
+ it("answers CORS preflight for localhost and github.io origins", async () => {
+ const base = await listen(createRunnerServer(createStubAdapter()));
+ const res = await fetch(`${base}/v1/chat/completions`, {
+ method: "OPTIONS",
+ headers: { Origin: "https://example.github.io" },
+ });
+ assert.equal(res.status, 204);
+ assert.equal(res.headers.get("access-control-allow-origin"), "https://example.github.io");
+
+ const denied = await fetch(`${base}/health`, {
+ headers: { Origin: "https://evil.example.com" },
+ });
+ assert.equal(denied.headers.get("access-control-allow-origin"), null);
+ });
+});
diff --git a/runner/src/server.ts b/runner/src/server.ts
new file mode 100644
index 0000000..8997d63
--- /dev/null
+++ b/runner/src/server.ts
@@ -0,0 +1,155 @@
+/**
+ * llm-fallbacks web-UI runner (R38): a user-run companion that exposes an
+ * OpenAI-shaped SSE endpoint backed by a configurable adapter. The public
+ * Pages demo never requires this process — it powers the opt-in web_ui tier.
+ *
+ * Endpoints:
+ * GET /health → { ok, adapter }
+ * POST /v1/chat/completions → OpenAI-style SSE (501 until an adapter is configured)
+ */
+import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
+import { readFileSync } from "node:fs";
+import { createGenericSelectorAdapter, type SelectorAdapterConfig } from "./adapters/generic-selector.ts";
+import { createStubAdapter } from "./adapters/stub.ts";
+import type { ChatMessage, RunnerAdapter } from "./adapters/types.ts";
+
+const DEFAULT_PORT = 8815;
+
+// Browser callers are the local dev shell or the GitHub Pages demo.
+const ALLOWED_ORIGIN_RE = /^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$|^https:\/\/[\w-]+\.github\.io$/;
+
+function applyCors(req: IncomingMessage, res: ServerResponse): void {
+ const origin = req.headers.origin;
+ if (origin && ALLOWED_ORIGIN_RE.test(origin)) {
+ res.setHeader("Access-Control-Allow-Origin", origin);
+ res.setHeader("Vary", "Origin");
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
+ }
+}
+
+function sendJson(res: ServerResponse, status: number, body: unknown): void {
+ res.writeHead(status, { "Content-Type": "application/json" });
+ res.end(JSON.stringify(body));
+}
+
+async function readBody(req: IncomingMessage): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of req) chunks.push(chunk as Buffer);
+ return Buffer.concat(chunks).toString("utf8");
+}
+
+function sseChunk(payload: unknown): string {
+ return `data: ${JSON.stringify(payload)}\n\n`;
+}
+
+async function handleChat(
+ adapter: RunnerAdapter | null,
+ req: IncomingMessage,
+ res: ServerResponse
+): Promise {
+ if (!adapter) {
+ sendJson(res, 501, {
+ error: {
+ message:
+ "No runner adapter configured. Create runner.config.json (see runner/README.md) and restart.",
+ },
+ });
+ return;
+ }
+
+ let messages: ChatMessage[];
+ try {
+ const parsed = JSON.parse(await readBody(req)) as { messages?: ChatMessage[] };
+ messages = parsed.messages ?? [];
+ } catch {
+ sendJson(res, 400, { error: { message: "Invalid JSON body" } });
+ return;
+ }
+ if (messages.length === 0) {
+ sendJson(res, 400, { error: { message: "messages[] is required" } });
+ return;
+ }
+
+ res.writeHead(200, {
+ "Content-Type": "text/event-stream; charset=utf-8",
+ "Cache-Control": "no-cache",
+ Connection: "keep-alive",
+ });
+
+ const abort = new AbortController();
+ req.on("close", () => abort.abort());
+
+ try {
+ await adapter.streamReply(
+ messages,
+ (delta) => {
+ res.write(sseChunk({ choices: [{ delta: { content: delta } }] }));
+ },
+ abort.signal
+ );
+ res.write(sseChunk({ choices: [{ delta: {}, finish_reason: "stop" }] }));
+ res.write("data: [DONE]\n\n");
+ } catch (err) {
+ // Headers are already sent; surface the failure as an SSE error payload.
+ const message = err instanceof Error ? err.message : String(err);
+ res.write(sseChunk({ error: { message: `runner adapter "${adapter.name}" failed: ${message}` } }));
+ }
+ res.end();
+}
+
+export function createRunnerServer(adapter: RunnerAdapter | null): Server {
+ return createServer((req, res) => {
+ applyCors(req, res);
+ if (req.method === "OPTIONS") {
+ res.writeHead(204);
+ res.end();
+ return;
+ }
+ if (req.method === "GET" && req.url === "/health") {
+ sendJson(res, 200, { ok: true, adapter: adapter?.name ?? null });
+ return;
+ }
+ if (req.method === "POST" && req.url === "/v1/chat/completions") {
+ void handleChat(adapter, req, res);
+ return;
+ }
+ sendJson(res, 404, { error: { message: "Not found" } });
+ });
+}
+
+interface RunnerConfig {
+ adapter?: "stub" | "generic-selector";
+ stubReply?: string;
+ port?: number;
+ selector?: SelectorAdapterConfig;
+}
+
+export function adapterFromConfig(config: RunnerConfig): RunnerAdapter | null {
+ if (config.adapter === "stub") return createStubAdapter(config.stubReply);
+ if (config.adapter === "generic-selector" && config.selector) {
+ return createGenericSelectorAdapter(config.selector);
+ }
+ return null;
+}
+
+function loadConfig(path: string): RunnerConfig {
+ try {
+ return JSON.parse(readFileSync(path, "utf8")) as RunnerConfig;
+ } catch {
+ return {};
+ }
+}
+
+const isMain = process.argv[1]?.endsWith("server.ts") || process.argv[1]?.endsWith("server.js");
+if (isMain) {
+ const configPath = process.env.RUNNER_CONFIG ?? new URL("../runner.config.json", import.meta.url).pathname;
+ const config = loadConfig(configPath);
+ const adapter = adapterFromConfig(config);
+ const port = config.port ?? (Number(process.env.PORT) || DEFAULT_PORT);
+ createRunnerServer(adapter).listen(port, "127.0.0.1", () => {
+ console.log(
+ `llm-fallbacks runner on http://127.0.0.1:${port} — adapter: ${adapter?.name ?? "none (chat returns 501)"}`
+ );
+ });
+}
diff --git a/runner/tsconfig.json b/runner/tsconfig.json
new file mode 100644
index 0000000..e712035
--- /dev/null
+++ b/runner/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "lib": ["ES2022", "DOM"],
+ "strict": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "types": ["node"]
+ },
+ "include": ["src"]
+}
diff --git a/tests/e2e/compare-mode.spec.ts b/tests/e2e/compare-mode.spec.ts
new file mode 100644
index 0000000..18cb53d
--- /dev/null
+++ b/tests/e2e/compare-mode.spec.ts
@@ -0,0 +1,54 @@
+import { test, expect } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installLocalChatBundle,
+ installTestConfigMock,
+ mockProxySse,
+} from "./helpers";
+
+test.describe("Wave 4B — compare mode", () => {
+ test.beforeEach(async ({ page }) => {
+ let call = 0;
+ await installTestConfigMock(page);
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ call += 1;
+ const reply = call === 1 ? "reply from column A" : "reply from column B";
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream; charset=utf-8",
+ body: mockProxySse(reply),
+ });
+ });
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#lf-compare-toggle")).toBeVisible({ timeout: 45_000 });
+ });
+
+ test("toggle shows grid, dual replies, exit keeps history", async ({ page }) => {
+ const toggle = page.locator("#lf-compare-toggle");
+ await toggle.evaluate((el: HTMLInputElement) => {
+ el.checked = true;
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await expect(page.locator(".lf-compare-grid")).toBeVisible();
+ await expect(page.locator("#lf-compare-banner")).toBeVisible();
+ await expect(page.locator("#lf-compare-banner")).toContainText(/two requests/i);
+
+ await page.locator("#chatinput").fill("compare please");
+ await page.locator("#sendbutton").click({ force: true });
+
+ await expect(page.locator('[data-pane="a"]')).toContainText("column A", { timeout: 30_000 });
+ await expect(page.locator('[data-pane="b"]')).toContainText("column B", { timeout: 30_000 });
+
+ // History keeps compare turns after exit (R35).
+ await toggle.evaluate((el: HTMLInputElement) => {
+ el.checked = false;
+ el.dispatchEvent(new Event("change", { bubbles: true }));
+ });
+ await expect(page.locator(".lf-compare-chrome")).toBeHidden();
+ await expect(page.locator(".mur-message-user").last()).toContainText("compare please");
+ await expect(page.locator(".mur-message-assistant")).toHaveCount(2, { timeout: 15_000 });
+ });
+});
diff --git a/tests/e2e/helpers.ts b/tests/e2e/helpers.ts
index 8779225..5fd289f 100644
--- a/tests/e2e/helpers.ts
+++ b/tests/e2e/helpers.ts
@@ -200,6 +200,26 @@ export async function installLocalChatBundle(page: Page): Promise {
});
}
+/**
+ * Serve the locally built docs/index.html instead of the deployed page.
+ * Needed when a spec depends on static markup (e.g. the Tiers button) that
+ * has not shipped to GitHub Pages yet. Pair with installLocalChatBundle.
+ */
+export async function installLocalIndexHtml(page: Page): Promise {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const fs = require("node:fs") as typeof import("node:fs");
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const path = require("node:path") as typeof import("node:path");
+ const body = fs.readFileSync(path.join(process.cwd(), "docs/index.html"), "utf8");
+ await page.route("**/*", async (route) => {
+ if (route.request().resourceType() !== "document") {
+ await route.fallback();
+ return;
+ }
+ await route.fulfill({ status: 200, contentType: "text/html; charset=utf-8", body });
+ });
+}
+
export function readStoredEndpoints(page: Page): Promise {
return page.evaluate(() => {
try {
diff --git a/tests/e2e/tier-settings.spec.ts b/tests/e2e/tier-settings.spec.ts
new file mode 100644
index 0000000..791e315
--- /dev/null
+++ b/tests/e2e/tier-settings.spec.ts
@@ -0,0 +1,71 @@
+import { test, expect } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installLocalChatBundle,
+ installLocalIndexHtml,
+ installTestConfigMock,
+ mockProxySse,
+} from "./helpers";
+
+const TIERS_KEY = "llm_fallbacks_provider_tiers";
+
+test.describe("Wave 4B — tier settings panel", () => {
+ test.beforeEach(async ({ page }) => {
+ await installTestConfigMock(page);
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream; charset=utf-8",
+ body: mockProxySse("proxy reply"),
+ });
+ });
+ await installLocalChatBundle(page);
+ await installLocalIndexHtml(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#tiersSetting")).toBeVisible({ timeout: 45_000 });
+ });
+
+ test("reorder persists to localStorage and survives reload (R36)", async ({ page }) => {
+ await page.locator("#tiersSetting").click();
+ const panel = page.locator("#shell-panel-tiers");
+ await expect(panel).toBeVisible();
+ await expect(panel.locator(".lf-tier-row")).toHaveCount(4);
+
+ // Default order starts with quality_api; move proxy_failover to the top.
+ await panel.locator('[data-tier-up="proxy_failover"]').click();
+ await panel.locator('[data-tier-up="proxy_failover"]').click();
+ await panel.locator('[data-tier-up="proxy_failover"]').click();
+ await expect(panel.locator(".lf-tier-row").first()).toHaveAttribute(
+ "data-tier-id",
+ "proxy_failover"
+ );
+
+ await panel.locator("#lf-tier-save").click();
+ await expect(panel.locator("#lf-tier-status")).toContainText(/Saved order: proxy_failover/);
+
+ const stored = await page.evaluate(
+ (key) => JSON.parse(localStorage.getItem(key) || "null"),
+ TIERS_KEY
+ );
+ expect(stored?.tiers?.[0]).toEqual({ id: "proxy_failover", enabled: true });
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator("#tiersSetting")).toBeVisible({ timeout: 45_000 });
+ await page.locator("#tiersSetting").click();
+ await expect(
+ page.locator("#shell-panel-tiers .lf-tier-row").first()
+ ).toHaveAttribute("data-tier-id", "proxy_failover");
+ });
+
+ test("zero-config chat still streams with web/searxng tiers untouched (AE3)", async ({
+ page,
+ }) => {
+ await page.locator("#chatinput").fill("hello zero config");
+ await page.locator("#sendbutton").click({ force: true });
+ await expect(page.locator(".mur-message-assistant").last()).toContainText("proxy reply", {
+ timeout: 30_000,
+ });
+ });
+});
diff --git a/tests/e2e/vision-attach.spec.ts b/tests/e2e/vision-attach.spec.ts
new file mode 100644
index 0000000..790ab1a
--- /dev/null
+++ b/tests/e2e/vision-attach.spec.ts
@@ -0,0 +1,96 @@
+import { test, expect, type Page } from "@playwright/test";
+import {
+ DEMO_PROXY,
+ installLocalChatBundle,
+ installTestConfigMock,
+ mockProxySse,
+ waitForAssistantText,
+} from "./helpers";
+
+const MOCK_CATALOG = [
+ {
+ id: "groq/llama-3.3-70b",
+ provider: "groq",
+ quality_score: 9.2,
+ context_length: 128000,
+ supports_vision: true,
+ },
+ { id: "google/gemini-2.0-flash", provider: "google", quality_score: 8.5 },
+];
+
+// 1x1 transparent PNG
+const TINY_PNG = Buffer.from(
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==",
+ "base64"
+);
+
+async function attachPng(page: Page): Promise {
+ await page.setInputFiles('input[type="file"]', {
+ name: "tiny.png",
+ mimeType: "image/png",
+ buffer: TINY_PNG,
+ });
+ await expect(page.locator(".mur-attachment-preview-item")).toBeVisible({ timeout: 15_000 });
+}
+
+test.describe("Wave 4B — vision attachments", () => {
+ let lastProxyBody: { messages?: { role: string; content: unknown }[] } | null;
+
+ test.beforeEach(async ({ page }) => {
+ lastProxyBody = null;
+ await installTestConfigMock(page);
+ await page.route("**/free_models.json", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(MOCK_CATALOG),
+ });
+ });
+ await page.route(`${DEMO_PROXY}/v1/chat/completions`, async (route) => {
+ lastProxyBody = route.request().postDataJSON();
+ await route.fulfill({
+ status: 200,
+ contentType: "text/event-stream; charset=utf-8",
+ body: mockProxySse("I can see a tiny transparent pixel."),
+ });
+ });
+ await installLocalChatBundle(page);
+ await page.goto("./", { waitUntil: "domcontentloaded" });
+ await page.evaluate(() => localStorage.clear());
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(page.locator(".lf-model-picker-select")).toBeVisible({ timeout: 45_000 });
+ });
+
+ test("PNG + vision model streams a reply and sends image_url to the proxy (AE1)", async ({
+ page,
+ }) => {
+ await page.locator(".lf-model-picker-select").selectOption("groq/llama-3.3-70b");
+ await attachPng(page);
+
+ await page.locator("#chatinput").fill("what is in this image?");
+ await page.locator("#sendbutton").click({ force: true });
+
+ const reply = await waitForAssistantText(page);
+ expect(reply).toContain("tiny transparent pixel");
+
+ expect(lastProxyBody).not.toBeNull();
+ const userMessage = lastProxyBody?.messages?.at(-1);
+ expect(Array.isArray(userMessage?.content)).toBe(true);
+ const parts = userMessage?.content as { type: string; image_url?: { url: string } }[];
+ const imagePart = parts.find((p) => p.type === "image_url");
+ expect(imagePart?.image_url?.url).toMatch(/^data:image\/png;base64,/);
+ });
+
+ test("PNG on a non-vision model blocks with a clear message (R29)", async ({ page }) => {
+ // Default model stays `free` (not vision-capable in the catalog).
+ await attachPng(page);
+
+ await page.locator("#chatinput").fill("describe this image");
+ await page.locator("#sendbutton").click({ force: true });
+
+ await expect(page.locator("#chatMount")).toContainText(/can't read images/i, {
+ timeout: 30_000,
+ });
+ expect(lastProxyBody).toBeNull();
+ });
+});
diff --git a/webui/index.template.html b/webui/index.template.html
index a666087..d3e4c82 100644
--- a/webui/index.template.html
+++ b/webui/index.template.html
@@ -24,6 +24,7 @@
Server
Your keys
+
Models
@@ -67,6 +68,7 @@
+
× Close
diff --git a/webui/shell/chat-overrides.css b/webui/shell/chat-overrides.css
index ad20d36..f5a1205 100644
--- a/webui/shell/chat-overrides.css
+++ b/webui/shell/chat-overrides.css
@@ -960,3 +960,255 @@ body.lf-chat-page::before {
color: #c77dff;
}
+/* Wave 4B: image attachment tray — inherits murm-ui attachment vars from the
+ dark theme block above; these rules just tighten thumbnail framing so the
+ composer tray matches the dark shell. */
+#chatMount .mur-attachment-previews {
+ gap: 0.4rem;
+}
+
+#chatMount .mur-attachment-preview-item img,
+#chatMount .mur-attachment-image {
+ border-radius: 8px;
+ border: 1px solid var(--mur-border);
+}
+
+/* Wave 4B: provider tier settings panel */
+.lf-tier-list {
+ list-style: none;
+ margin: 0 0 1rem;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.lf-tier-row {
+ border: 1px solid rgba(157, 78, 221, 0.22);
+ border-radius: 10px;
+ padding: 0.65rem 0.75rem;
+ background: rgba(26, 26, 46, 0.55);
+}
+
+.lf-tier-row-main {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem;
+}
+
+.lf-tier-enable {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0;
+ font-weight: 600;
+ color: #e8e8ef;
+}
+
+.lf-tier-enable input {
+ width: auto;
+ margin: 0;
+}
+
+.lf-tier-move {
+ display: flex;
+ gap: 0.25rem;
+ flex-shrink: 0;
+}
+
+.lf-tier-move .panel-btn {
+ min-width: 2rem;
+ padding: 0.2rem 0.45rem;
+}
+
+.lf-tier-hint {
+ margin: 0.35rem 0 0;
+}
+
+/* Wave 4B: compare mode split-pane */
+.lf-compare-toggle-row {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ flex-wrap: wrap;
+ padding: 0.35rem 0.15rem 0.55rem;
+}
+
+.lf-compare-toggle {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ margin: 0;
+ font-weight: 600;
+ color: #e8e8ef;
+ cursor: pointer;
+}
+
+.lf-compare-toggle input {
+ width: auto;
+ margin: 0;
+}
+
+.lf-compare-toggle-hint {
+ font-size: 0.78rem;
+ color: #8b8ba3;
+}
+
+.lf-compare-chrome {
+ padding: 0.5rem 0.75rem 0.85rem;
+ border-top: 1px solid rgba(157, 78, 221, 0.22);
+ background: rgba(18, 18, 31, 0.92);
+ flex-shrink: 0;
+}
+
+.lf-compare-chrome[hidden] {
+ display: none !important;
+}
+
+.lf-compare-banner {
+ margin: 0 0 0.65rem;
+ padding: 0.55rem 0.7rem;
+ border-radius: 8px;
+ border: 1px solid rgba(248, 113, 113, 0.35);
+ background: rgba(127, 29, 29, 0.28);
+ color: #fecaca;
+ font-size: 0.82rem;
+}
+
+.lf-compare-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 0.75rem;
+}
+
+@media (max-width: 720px) {
+ .lf-compare-grid {
+ grid-template-columns: 1fr;
+ }
+}
+
+.lf-compare-column {
+ border: 1px solid rgba(157, 78, 221, 0.22);
+ border-radius: 10px;
+ background: rgba(26, 26, 46, 0.65);
+ min-height: 8rem;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.lf-compare-column-header {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ padding: 0.55rem 0.65rem;
+ border-bottom: 1px solid rgba(157, 78, 221, 0.18);
+}
+
+.lf-compare-column-title {
+ display: block;
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #8b8ba3;
+ margin-bottom: 0.2rem;
+}
+
+.lf-compare-model {
+ width: 100%;
+ max-width: 100%;
+}
+
+.lf-compare-live-label {
+ font-size: 0.75rem;
+ color: #c77dff;
+ word-break: break-all;
+}
+
+.lf-compare-pane {
+ flex: 1;
+ padding: 0.65rem 0.75rem;
+ white-space: pre-wrap;
+ color: #e8e8ef;
+ font-size: 0.92rem;
+ line-height: 1.45;
+ min-height: 5rem;
+}
+
+.lf-compare-pane:empty::before {
+ content: "Waiting for reply…";
+ color: #8b8ba3;
+ font-style: italic;
+}
+
+#chatMount.lf-compare-active .mur-chat-history {
+ /* Keep history visible above the live compare panes */
+ max-height: 42vh;
+}
+
+/* Wave 4B: SearXNG discovery pick list */
+.lf-discovery-picklist {
+ flex-shrink: 0;
+ padding: 0.55rem 0.75rem;
+ border-top: 1px solid rgba(157, 78, 221, 0.22);
+ background: rgba(18, 18, 31, 0.92);
+}
+
+.lf-discovery-picklist[hidden] {
+ display: none !important;
+}
+
+.lf-discovery-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+ font-size: 0.8rem;
+ color: #c4c4d4;
+}
+
+.lf-discovery-dismiss {
+ min-width: 1.8rem;
+ padding: 0.1rem 0.4rem;
+}
+
+.lf-discovery-list {
+ list-style: none;
+ margin: 0.5rem 0 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.lf-discovery-item {
+ border: 1px solid rgba(157, 78, 221, 0.22);
+ border-radius: 8px;
+ padding: 0.5rem 0.65rem;
+ background: rgba(26, 26, 46, 0.6);
+}
+
+.lf-discovery-item a {
+ color: #c77dff;
+ font-weight: 600;
+ text-decoration: none;
+}
+
+.lf-discovery-item a:hover {
+ text-decoration: underline;
+}
+
+.lf-discovery-url {
+ display: block;
+ font-size: 0.72rem;
+ color: #8b8ba3;
+ word-break: break-all;
+}
+
+.lf-discovery-snippet {
+ margin: 0.3rem 0 0;
+ font-size: 0.8rem;
+ color: #c4c4d4;
+}
+
diff --git a/webui/src/main.ts b/webui/src/main.ts
index 31db069..b60ca27 100644
--- a/webui/src/main.ts
+++ b/webui/src/main.ts
@@ -1,5 +1,6 @@
import { ChatUI, IndexedDBStorage, type ChatEngine } from "murm-ui/with-css";
import { CopyPlugin } from "murm-ui/plugins/copy";
+import { AttachmentPlugin } from "murm-ui/plugins/attachment";
import {
loadRuntimeConfig,
readRuntimeConfig,
@@ -10,6 +11,9 @@ import { FailoverProvider } from "./providers/FailoverProvider";
import type { CatalogEntry } from "./providers/browser-router";
import { FailoverSettingsPlugin } from "./plugins/failover-settings";
import { ByokSettingsPlugin } from "./plugins/byok-settings";
+import { TierSettingsPlugin } from "./plugins/tier-settings";
+import { CompareModePlugin } from "./plugins/compare-mode";
+import { DiscoveryPicklistPlugin } from "./plugins/discovery-picklist";
import { ModelExplorerPlugin } from "./plugins/model-explorer";
import { ModelPickerPlugin } from "./plugins/model-picker";
import { RoutingChipPlugin } from "./plugins/routing-chip";
@@ -36,6 +40,9 @@ import {
import { ShortcutsSheetPlugin } from "./plugins/shortcuts-sheet";
import { showStatusMessage } from "./plugins/status-strip";
+// Published client-side image attachment cap (R31).
+const MAX_IMAGE_ATTACHMENT_BYTES = 4_000_000;
+
async function loadCatalog(config: AppConfig): Promise<{
catalog: CatalogEntry[];
providerUrls: Record;
@@ -202,6 +209,19 @@ async function bootstrap(): Promise {
},
plugins: (engine) => [
CopyPlugin(),
+ AttachmentPlugin({
+ acceptedTypes: "image/*",
+ maxFileSize: MAX_IMAGE_ATTACHMENT_BYTES,
+ onSizeExceeded: (file, maxSize) => {
+ const limitMb = Math.round(maxSize / 1_000_000);
+ showStatusMessage(
+ `"${file.name}" is too large. Images must be under ${limitMb} MB.`
+ );
+ },
+ onUnsupportedFile: (file) => {
+ showStatusMessage(`"${file.name}" isn't a supported image type.`);
+ },
+ }),
ModelPickerPlugin(),
MessageActionsPlugin(),
RoutingChipPlugin(),
@@ -224,6 +244,12 @@ async function bootstrap(): Promise {
provider.setCatalog(catalogRef, providerUrlsRef);
},
}),
+ TierSettingsPlugin(),
+ CompareModePlugin({
+ provider,
+ getCatalog: () => catalogRef,
+ }),
+ DiscoveryPicklistPlugin(),
ModelExplorerPlugin({
getCatalog: () => catalogRef,
getCatalogUrl: () => readRuntimeConfig().catalogUrl,
diff --git a/webui/src/plugins/compare-mode/column-provider.test.ts b/webui/src/plugins/compare-mode/column-provider.test.ts
new file mode 100644
index 0000000..2c6e726
--- /dev/null
+++ b/webui/src/plugins/compare-mode/column-provider.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, it } from "vitest";
+import {
+ METERED_COMPARE_BANNER,
+ bothColumnsMetered,
+ columnIsMetered,
+ defaultCompareState,
+} from "./column-provider";
+import { STORAGE_KEYS, saveJson } from "../../storage-keys";
+import type { CatalogEntry } from "../../providers/browser-router";
+
+const catalog: CatalogEntry[] = [
+ { id: "groq/llama", provider: "groq", supports_vision: false },
+ { id: "openrouter/free", provider: "openrouter" },
+];
+
+describe("compare column helpers", () => {
+ it("defaults to inactive with free vs openrouter/free", () => {
+ const state = defaultCompareState("free");
+ expect(state.active).toBe(false);
+ expect(state.columns.a.model).toBe("free");
+ expect(state.columns.b.model).toBe("openrouter/free");
+ });
+
+ it("treats free as metered (public proxy)", () => {
+ expect(columnIsMetered("free", catalog, {})).toBe(true);
+ });
+
+ it("treats a BYOK-backed model as not metered", () => {
+ expect(columnIsMetered("groq/llama", catalog, { groq: "sk-test" })).toBe(false);
+ });
+
+ it("flags both-columns-metered for R34 banner", () => {
+ localStorage.clear();
+ saveJson(STORAGE_KEYS.apiKeys, {});
+ const state = defaultCompareState();
+ state.active = true;
+ state.columns.a.model = "free";
+ state.columns.b.model = "openrouter/free";
+ expect(bothColumnsMetered(state, catalog, {})).toBe(true);
+ expect(METERED_COMPARE_BANNER).toMatch(/two requests/i);
+ });
+
+ it("does not flag metered banner when one column is BYOK", () => {
+ const state = defaultCompareState();
+ state.active = true;
+ state.columns.a.model = "free";
+ state.columns.b.model = "groq/llama";
+ expect(bothColumnsMetered(state, catalog, { groq: "sk-test" })).toBe(false);
+ });
+});
diff --git a/webui/src/plugins/compare-mode/column-provider.ts b/webui/src/plugins/compare-mode/column-provider.ts
new file mode 100644
index 0000000..b74d2b2
--- /dev/null
+++ b/webui/src/plugins/compare-mode/column-provider.ts
@@ -0,0 +1,60 @@
+import type { CatalogEntry } from "../../providers/browser-router";
+import { hasKeyForModel, loadKeys, shouldTryBrowser } from "../../providers/browser-router";
+
+export type CompareColumnId = "a" | "b";
+
+export interface CompareColumnConfig {
+ model: string;
+}
+
+export interface CompareState {
+ active: boolean;
+ columns: Record;
+}
+
+export function defaultCompareState(activeModel = "free"): CompareState {
+ return {
+ active: false,
+ columns: {
+ a: { model: activeModel || "free" },
+ b: { model: "openrouter/free" },
+ },
+ };
+}
+
+/**
+ * A column is "metered" when it will hit the shared public proxy (no usable
+ * BYOK key for that model). Compare of two metered columns costs two proxy
+ * requests — rate limits and Turnstile apply twice (R34).
+ */
+export function columnIsMetered(
+ model: string,
+ catalog: readonly CatalogEntry[],
+ keys = loadKeys()
+): boolean {
+ if (model === "free") return true;
+ return !shouldTryBrowser(model, catalog as CatalogEntry[], keys);
+}
+
+export function bothColumnsMetered(
+ state: CompareState,
+ catalog: readonly CatalogEntry[],
+ keys = loadKeys()
+): boolean {
+ if (!state.active) return false;
+ return (
+ columnIsMetered(state.columns.a.model, catalog, keys) &&
+ columnIsMetered(state.columns.b.model, catalog, keys)
+ );
+}
+
+export function compareUsesByok(
+ model: string,
+ keys = loadKeys()
+): boolean {
+ if (model === "free") return false;
+ return hasKeyForModel(model, keys);
+}
+
+export const METERED_COMPARE_BANNER =
+ "Compare sends two requests. Rate limits and Turnstile apply to each column when both use the public proxy.";
diff --git a/webui/src/plugins/compare-mode/index.ts b/webui/src/plugins/compare-mode/index.ts
new file mode 100644
index 0000000..6ce3c04
--- /dev/null
+++ b/webui/src/plugins/compare-mode/index.ts
@@ -0,0 +1,256 @@
+import type { ChatPlugin, ChatRequest, StreamEvent } from "murm-ui";
+import {
+ getActiveModel,
+ getCatalogModels,
+ getPinnedModels,
+ modelOptionLabel,
+} from "../../model-selection";
+import type { CatalogEntry } from "../../providers/browser-router";
+import type { FailoverProvider } from "../../providers/FailoverProvider";
+import { showStatusMessage } from "../status-strip";
+import {
+ METERED_COMPARE_BANNER,
+ bothColumnsMetered,
+ defaultCompareState,
+ type CompareState,
+} from "./column-provider";
+
+function modelOptionsHtml(selected: string): string {
+ const parts: string[] = [];
+ for (const pinned of getPinnedModels()) {
+ parts.push(
+ `${pinned.label} `
+ );
+ }
+ for (const entry of getCatalogModels(40)) {
+ if (getPinnedModels().some((p) => p.id === entry.id)) continue;
+ parts.push(
+ `${modelOptionLabel(entry)} `
+ );
+ }
+ return parts.join("");
+}
+
+function applyDeltaToPane(pane: HTMLElement, event: StreamEvent): void {
+ if (event.type === "message_start") {
+ pane.textContent = "";
+ return;
+ }
+ if (event.type === "text_delta") {
+ pane.textContent = (pane.textContent || "") + event.delta;
+ }
+}
+
+/** Tag message_start so history rows carry compare column + model (R33). */
+function tagCompareMeta(
+ event: StreamEvent,
+ column: "a" | "b",
+ model: string
+): StreamEvent {
+ if (event.type !== "message_start") return event;
+ return {
+ ...event,
+ message: {
+ ...event.message,
+ meta: { ...(event.message.meta || {}), compareColumn: column, model },
+ },
+ };
+}
+
+export function CompareModePlugin(deps: {
+ provider: FailoverProvider;
+ getCatalog: () => CatalogEntry[];
+}): ChatPlugin {
+ let state: CompareState = defaultCompareState(getActiveModel());
+ let mount: HTMLElement | null = null;
+ let chrome: HTMLElement | null = null;
+ let bannerEl: HTMLElement | null = null;
+ let paneA: HTMLElement | null = null;
+ let paneB: HTMLElement | null = null;
+ let labelA: HTMLElement | null = null;
+ let labelB: HTMLElement | null = null;
+ let wrapped = false;
+
+ const refreshBanner = (): void => {
+ if (!bannerEl) return;
+ const show = bothColumnsMetered(state, deps.getCatalog());
+ bannerEl.hidden = !show;
+ bannerEl.textContent = show ? METERED_COMPARE_BANNER : "";
+ };
+
+ const syncChromeVisibility = (): void => {
+ if (!chrome || !mount) return;
+ chrome.hidden = !state.active;
+ mount.classList.toggle("lf-compare-active", state.active);
+ refreshBanner();
+ };
+
+ const paintColumnLabels = (): void => {
+ if (labelA) labelA.textContent = state.columns.a.model;
+ if (labelB) labelB.textContent = state.columns.b.model;
+ };
+
+ return {
+ name: "compare-mode",
+ onMount(ctx) {
+ mount = ctx.container;
+
+ chrome = document.createElement("div");
+ chrome.className = "lf-compare-chrome";
+ chrome.hidden = true;
+ chrome.innerHTML = `
+
+
+ `;
+
+ const layout = mount.querySelector(".mur-chat-layout-wrapper");
+ const formHost = mount.querySelector(".mur-chat-form-container");
+ if (layout && formHost) {
+ layout.insertBefore(chrome, formHost);
+ } else {
+ (mount.querySelector(".mur-chat-scroll-area") || mount).appendChild(chrome);
+ }
+
+ bannerEl = chrome.querySelector("#lf-compare-banner");
+ paneA = chrome.querySelector('[data-pane="a"]');
+ paneB = chrome.querySelector('[data-pane="b"]');
+ labelA = chrome.querySelector('[data-label="a"]');
+ labelB = chrome.querySelector('[data-label="b"]');
+
+ const selectA = chrome.querySelector('select[data-column="a"]')!;
+ const selectB = chrome.querySelector('select[data-column="b"]')!;
+ selectA.innerHTML = modelOptionsHtml(state.columns.a.model);
+ selectB.innerHTML = modelOptionsHtml(state.columns.b.model);
+ selectA.addEventListener("change", () => {
+ state.columns.a.model = selectA.value;
+ paintColumnLabels();
+ refreshBanner();
+ });
+ selectB.addEventListener("change", () => {
+ state.columns.b.model = selectB.value;
+ paintColumnLabels();
+ refreshBanner();
+ });
+ paintColumnLabels();
+
+ if (formHost) {
+ const toggleRow = document.createElement("div");
+ toggleRow.className = "lf-compare-toggle-row";
+ toggleRow.innerHTML = `
+
+
+ Compare mode
+
+ Same prompt → two models side by side
+ `;
+ formHost.insertBefore(toggleRow, formHost.firstChild);
+ const checkbox = toggleRow.querySelector("#lf-compare-toggle")!;
+ checkbox.addEventListener("change", () => {
+ state.active = checkbox.checked;
+ if (state.active) {
+ state.columns.a.model = selectA.value || getActiveModel();
+ selectA.value = state.columns.a.model;
+ paintColumnLabels();
+ showStatusMessage("Compare mode on — replies appear in both columns.");
+ } else {
+ showStatusMessage("Compare mode off — history kept.");
+ }
+ syncChromeVisibility();
+ });
+ }
+
+ syncChromeVisibility();
+
+ if (!wrapped) {
+ wrapped = true;
+ const original = deps.provider.streamChat.bind(deps.provider);
+ deps.provider.streamChat = async (
+ request: ChatRequest,
+ onEvent: (event: StreamEvent) => void
+ ): Promise => {
+ if (!state.active) {
+ return original(request, onEvent);
+ }
+
+ refreshBanner();
+ if (paneA) paneA.textContent = "";
+ if (paneB) paneB.textContent = "";
+ paintColumnLabels();
+
+ const modelA = state.columns.a.model;
+ const modelB = state.columns.b.model;
+ const reqA: ChatRequest = {
+ ...request,
+ options: { ...request.options, model: modelA },
+ };
+ const reqB: ChatRequest = {
+ ...request,
+ options: { ...request.options, model: modelB },
+ };
+
+ // Sequential through onEvent so murm-ui appends two assistants in one
+ // generation (multiple message_start). setMessages cannot run while busy.
+ try {
+ await original(reqA, (event) => {
+ if (paneA) applyDeltaToPane(paneA, event);
+ onEvent(tagCompareMeta(event, "a", modelA));
+ });
+ } catch (err) {
+ if (paneA && !paneA.textContent) {
+ paneA.textContent = err instanceof Error ? err.message : String(err);
+ }
+ throw err;
+ }
+
+ try {
+ await original(reqB, (event) => {
+ if (paneB) applyDeltaToPane(paneB, event);
+ onEvent(tagCompareMeta(event, "b", modelB));
+ });
+ } catch (err) {
+ if (paneB && !paneB.textContent) {
+ paneB.textContent = err instanceof Error ? err.message : String(err);
+ }
+ // Column B failure must not erase column A's history message.
+ }
+ };
+ }
+ },
+ beforeSubmit: async (request) => {
+ if (!state.active) return;
+ refreshBanner();
+ return {
+ options: {
+ ...request.options,
+ model: state.columns.a.model,
+ lfCompare: true,
+ },
+ };
+ },
+ destroy() {
+ chrome?.remove();
+ mount?.classList.remove("lf-compare-active");
+ },
+ };
+}
diff --git a/webui/src/plugins/discovery-picklist/index.ts b/webui/src/plugins/discovery-picklist/index.ts
new file mode 100644
index 0000000..805f299
--- /dev/null
+++ b/webui/src/plugins/discovery-picklist/index.ts
@@ -0,0 +1,79 @@
+import type { ChatPlugin } from "murm-ui";
+import {
+ DISCOVERY_RESULTS_EVENT,
+ type DiscoveryCandidate,
+} from "../../providers/tiers/searxng-discovery-tier";
+
+function escapeHtml(text: string): string {
+ return text
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """);
+}
+
+function candidateRow(candidate: DiscoveryCandidate): string {
+ const title = escapeHtml(candidate.title);
+ const url = escapeHtml(candidate.url);
+ const snippet = escapeHtml(candidate.snippet);
+ return `
+
+ ${title}
+ ${url}
+ ${snippet ? `${snippet}
` : ""}
+
+ `;
+}
+
+/**
+ * Renders SearXNG discovery results as a dismissible pick list (Q5) between
+ * the chat history and the composer. Links only — the user decides what to
+ * open; nothing is automated on their behalf (R39).
+ */
+export function DiscoveryPicklistPlugin(): ChatPlugin {
+ let host: HTMLElement | null = null;
+ let handler: ((event: Event) => void) | null = null;
+
+ return {
+ name: "discovery-picklist",
+ onMount(ctx) {
+ host = document.createElement("div");
+ host.className = "lf-discovery-picklist";
+ host.hidden = true;
+
+ const layout = ctx.container.querySelector(".mur-chat-layout-wrapper");
+ const form = ctx.container.querySelector(".mur-chat-form-container");
+ if (layout && form) {
+ layout.insertBefore(host, form);
+ } else {
+ ctx.container.appendChild(host);
+ }
+
+ handler = (event: Event) => {
+ const detail = (event as CustomEvent<{ candidates: DiscoveryCandidate[] }>).detail;
+ const candidates = detail?.candidates ?? [];
+ if (!host || candidates.length === 0) return;
+ host.innerHTML = `
+
+
+ ${candidates.map((c) => candidateRow(c)).join("")}
+
+ `;
+ host.hidden = false;
+ host
+ .querySelector(".lf-discovery-dismiss")
+ ?.addEventListener("click", () => {
+ if (host) host.hidden = true;
+ });
+ };
+ window.addEventListener(DISCOVERY_RESULTS_EVENT, handler);
+ },
+ destroy() {
+ if (handler) window.removeEventListener(DISCOVERY_RESULTS_EVENT, handler);
+ host?.remove();
+ },
+ };
+}
diff --git a/webui/src/plugins/tier-settings/index.ts b/webui/src/plugins/tier-settings/index.ts
new file mode 100644
index 0000000..ce92d30
--- /dev/null
+++ b/webui/src/plugins/tier-settings/index.ts
@@ -0,0 +1,132 @@
+import type { ChatPlugin } from "murm-ui";
+import { defaultProviderTierSettings } from "../../providers/tiers/defaults";
+import { loadProviderTierSettings } from "../../providers/tiers/settings";
+import type { ProviderTierSettings, TierId } from "../../providers/tiers/types";
+import {
+ TIER_HINTS,
+ TIER_LABELS,
+ moveTier,
+ persistTierSettings,
+ setTierEnabled,
+ updateCompanionUrls,
+} from "./settings";
+
+function renderTierList(settings: ProviderTierSettings): string {
+ return settings.tiers
+ .map((tier, index) => {
+ const label = TIER_LABELS[tier.id] ?? tier.id;
+ const hint = TIER_HINTS[tier.id] ?? "";
+ return `
+
+
+
+
+ ${label}
+
+
+ ↑
+ ↓
+
+
+ ${hint}
+
+ `;
+ })
+ .join("");
+}
+
+export function TierSettingsPlugin(): ChatPlugin {
+ return {
+ name: "tier-settings",
+ onMount() {
+ window.registerShellPanel?.("tiers", (root) => {
+ let draft = loadProviderTierSettings();
+
+ const paint = (): void => {
+ root.innerHTML = `
+
+
+ Ordered routes we try for each chat. This is the omnifail stack
+ (which route to attempt), not cloud free-tier rate limits.
+ Exhausting enabled tiers still fails honestly — we do not promise never-fail.
+
+ ${renderTierList(draft)}
+ Web-UI runner URL
+
+
+
+ Opt-in local companion. Off by default. You run it; it lowers pressure on the
+ public Worker demo. You are responsible for target-site terms — we do not
+ harvest credentials.
+
+ SearXNG URL
+
+
+
+ Opt-in self-hosted search. Empty disables discovery. Respect SearXNG and
+ target-site terms of service.
+
+
+ Reset defaults
+ Save tiers
+
+
+ `;
+
+ const list = root.querySelector("#lf-tier-list");
+ list?.addEventListener("click", (event) => {
+ const target = event.target as HTMLElement;
+ const up = target.closest("[data-tier-up]");
+ const down = target.closest("[data-tier-down]");
+ if (up?.dataset.tierUp) {
+ draft = moveTier(draft, up.dataset.tierUp as TierId, -1);
+ paint();
+ return;
+ }
+ if (down?.dataset.tierDown) {
+ draft = moveTier(draft, down.dataset.tierDown as TierId, 1);
+ paint();
+ }
+ });
+
+ list?.addEventListener("change", (event) => {
+ const input = event.target as HTMLInputElement;
+ const tierId = input.dataset.tierEnable as TierId | undefined;
+ if (!tierId || input.type !== "checkbox") return;
+ draft = setTierEnabled(draft, tierId, input.checked);
+ });
+
+ root.querySelector("#lf-tier-reset")?.addEventListener("click", () => {
+ draft = persistTierSettings(defaultProviderTierSettings());
+ paint();
+ const status = root.querySelector("#lf-tier-status");
+ if (status) status.textContent = "Restored zero-config defaults.";
+ });
+
+ root.querySelector("#lf-tier-save")?.addEventListener("click", () => {
+ const webRunnerUrl =
+ root.querySelector("#lf-web-runner-url")?.value ?? "";
+ const searxngUrl =
+ root.querySelector("#lf-searxng-url")?.value ?? "";
+ draft = updateCompanionUrls(draft, { webRunnerUrl, searxngUrl });
+ draft = persistTierSettings(draft);
+ paint();
+ const status = root.querySelector("#lf-tier-status");
+ if (status) {
+ status.textContent = `Saved order: ${draft.tiers
+ .filter((t) => t.enabled)
+ .map((t) => t.id)
+ .join(" → ") || "(none enabled)"}`;
+ }
+ });
+ };
+
+ paint();
+ });
+ },
+ };
+}
diff --git a/webui/src/plugins/tier-settings/settings.test.ts b/webui/src/plugins/tier-settings/settings.test.ts
new file mode 100644
index 0000000..f011ad7
--- /dev/null
+++ b/webui/src/plugins/tier-settings/settings.test.ts
@@ -0,0 +1,100 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ChatRequest, StreamEvent } from "murm-ui";
+import { defaultProviderTierSettings } from "../../providers/tiers/defaults";
+import { TierOrchestrator } from "../../providers/tiers/orchestrator";
+import { loadProviderTierSettings } from "../../providers/tiers/settings";
+import {
+ moveTier,
+ persistTierSettings,
+ setTierEnabled,
+ updateCompanionUrls,
+} from "./settings";
+
+const baseRequest: ChatRequest = {
+ messages: [{ id: "1", role: "user", blocks: [{ type: "text", text: "hi" }] }],
+ options: {},
+ signal: new AbortController().signal,
+};
+
+describe("tier-settings helpers", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("moves a tier up and preserves the new order through persistence", () => {
+ const start = defaultProviderTierSettings();
+ // Default order: quality_api, web_ui, searxng_discovery, proxy_failover
+ const moved = moveTier(start, "proxy_failover", -1);
+ expect(moved.tiers.map((t) => t.id)).toEqual([
+ "quality_api",
+ "web_ui",
+ "proxy_failover",
+ "searxng_discovery",
+ ]);
+
+ const saved = persistTierSettings(moved);
+ expect(loadProviderTierSettings().tiers.map((t) => t.id)).toEqual(
+ saved.tiers.map((t) => t.id)
+ );
+ });
+
+ it("persists enable toggles and companion URLs", () => {
+ let settings = defaultProviderTierSettings();
+ settings = setTierEnabled(settings, "web_ui", true);
+ settings = updateCompanionUrls(settings, {
+ webRunnerUrl: " http://127.0.0.1:8788 ",
+ searxngUrl: "http://127.0.0.1:8080",
+ });
+ const saved = persistTierSettings(settings);
+ expect(saved.tiers.find((t) => t.id === "web_ui")?.enabled).toBe(true);
+ expect(saved.webRunnerUrl).toBe("http://127.0.0.1:8788");
+ expect(saved.searxngUrl).toBe("http://127.0.0.1:8080");
+ });
+
+ it("reorder → localStorage → orchestrator attempt order matches (R36)", async () => {
+ // Put proxy_failover first, then quality_api. Both enabled.
+ const reordered = persistTierSettings({
+ ...defaultProviderTierSettings(),
+ tiers: [
+ { id: "proxy_failover", enabled: true },
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: false },
+ { id: "searxng_discovery", enabled: false },
+ ],
+ });
+ expect(reordered.tiers.map((t) => t.id).slice(0, 2)).toEqual([
+ "proxy_failover",
+ "quality_api",
+ ]);
+
+ const calls: string[] = [];
+ const proxyFailover = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => {
+ calls.push("proxy_failover");
+ onEvent({ type: "text_delta", delta: "from proxy" });
+ });
+ const qualityApi = vi.fn(async () => {
+ calls.push("quality_api");
+ });
+
+ const orchestrator = new TierOrchestrator({
+ qualityApi,
+ webUi: vi.fn(),
+ searxngDiscovery: vi.fn(),
+ proxyFailover,
+ });
+
+ await orchestrator.streamChat(baseRequest, () => {});
+
+ expect(calls).toEqual(["proxy_failover"]);
+ expect(proxyFailover).toHaveBeenCalledOnce();
+ expect(qualityApi).not.toHaveBeenCalled();
+ });
+
+ it("does not move past list ends", () => {
+ const start = defaultProviderTierSettings();
+ expect(moveTier(start, "quality_api", -1)).toEqual(start);
+ expect(moveTier(start, "proxy_failover", 1).tiers.map((t) => t.id)).toEqual(
+ start.tiers.map((t) => t.id)
+ );
+ });
+});
diff --git a/webui/src/plugins/tier-settings/settings.ts b/webui/src/plugins/tier-settings/settings.ts
new file mode 100644
index 0000000..48c9bda
--- /dev/null
+++ b/webui/src/plugins/tier-settings/settings.ts
@@ -0,0 +1,69 @@
+import {
+ loadProviderTierSettings,
+ saveProviderTierSettings,
+} from "../../providers/tiers/settings";
+import type { ProviderTierSettings, TierEntry, TierId } from "../../providers/tiers/types";
+import { normalizeTierSettings } from "../../providers/tiers/defaults";
+
+export const TIER_LABELS: Record = {
+ quality_api: "Direct / BYOK routes",
+ web_ui: "Local web-UI runner (opt-in)",
+ searxng_discovery: "SearXNG discovery (opt-in)",
+ proxy_failover: "Cloud proxy failover",
+};
+
+export const TIER_HINTS: Record = {
+ quality_api:
+ "Uses API keys stored in this browser. Skips when no key matches the selected model.",
+ web_ui:
+ "Optional local companion that drives a browser chat UI. Off by default — you run it.",
+ searxng_discovery:
+ "Optional self-hosted SearXNG. Suggests free chat URLs when higher tiers fail.",
+ proxy_failover:
+ "Public Worker / Render endpoints from Server settings. Serves zero-config visitors.",
+};
+
+/** Move a tier by delta (−1 = up, +1 = down). Returns a new settings object. */
+export function moveTier(
+ settings: ProviderTierSettings,
+ tierId: TierId,
+ delta: -1 | 1
+): ProviderTierSettings {
+ const tiers = settings.tiers.map((t) => ({ ...t }));
+ const from = tiers.findIndex((t) => t.id === tierId);
+ if (from < 0) return settings;
+ const to = from + delta;
+ if (to < 0 || to >= tiers.length) return settings;
+ const [entry] = tiers.splice(from, 1);
+ tiers.splice(to, 0, entry);
+ return normalizeTierSettings({ ...settings, tiers });
+}
+
+export function setTierEnabled(
+ settings: ProviderTierSettings,
+ tierId: TierId,
+ enabled: boolean
+): ProviderTierSettings {
+ const tiers: TierEntry[] = settings.tiers.map((t) =>
+ t.id === tierId ? { ...t, enabled } : { ...t }
+ );
+ return normalizeTierSettings({ ...settings, tiers });
+}
+
+export function updateCompanionUrls(
+ settings: ProviderTierSettings,
+ urls: { webRunnerUrl?: string; searxngUrl?: string }
+): ProviderTierSettings {
+ return normalizeTierSettings({
+ ...settings,
+ webRunnerUrl: urls.webRunnerUrl ?? settings.webRunnerUrl,
+ searxngUrl: urls.searxngUrl ?? settings.searxngUrl,
+ });
+}
+
+/** Round-trip helpers used by the panel save path and tests. */
+export function persistTierSettings(settings: ProviderTierSettings): ProviderTierSettings {
+ const normalized = normalizeTierSettings(settings);
+ saveProviderTierSettings(normalized);
+ return loadProviderTierSettings();
+}
diff --git a/webui/src/providers/FailoverProvider.tiers.test.ts b/webui/src/providers/FailoverProvider.tiers.test.ts
new file mode 100644
index 0000000..355ec95
--- /dev/null
+++ b/webui/src/providers/FailoverProvider.tiers.test.ts
@@ -0,0 +1,182 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ChatRequest, StreamEvent } from "murm-ui";
+import type { AppConfig } from "../config";
+
+vi.mock("../turnstile-session", () => ({
+ ensureTurnstileToken: async () => "",
+}));
+vi.mock("../plugins/status-strip", () => ({
+ showRateLimitBanner: () => {},
+}));
+vi.mock("../analytics", () => ({
+ trackChatCompletionSuccess: () => {},
+}));
+
+import { FailoverProvider } from "./FailoverProvider";
+
+const config: AppConfig = {
+ endpoints: ["https://proxy.test"],
+ guestToken: "guest",
+ defaultModel: "free",
+ catalogUrl: "",
+ providerUrlsUrl: "",
+ maxTokens: 256,
+};
+
+const PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANS";
+
+function request(): ChatRequest {
+ return {
+ messages: [{ id: "1", role: "user", blocks: [{ type: "text", text: "hi" }] }],
+ // Pin the model so resolveModel does not consult session state.
+ options: { model: "free" },
+ signal: new AbortController().signal,
+ };
+}
+
+function imageRequest(model: string): ChatRequest {
+ return {
+ messages: [
+ {
+ id: "1",
+ role: "user",
+ blocks: [
+ { type: "text", text: "what is this" },
+ { type: "file", mimeType: "image/png", name: "x.png", data: PNG_DATA_URL },
+ ],
+ },
+ ],
+ options: { model },
+ signal: new AbortController().signal,
+ };
+}
+
+function sseResponse(chunks: string[], init?: ResponseInit): Response {
+ const body = chunks.map((c) => `data: ${c}\n\n`).join("") + "data: [DONE]\n\n";
+ return new Response(body, { status: 200, ...init });
+}
+
+function collectDeltas(events: StreamEvent[]): string {
+ return events
+ .filter((e): e is Extract => e.type === "text_delta")
+ .map((e) => e.delta)
+ .join("");
+}
+
+describe("FailoverProvider tier routing", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("zero-config (no keys) skips quality_api and is served by proxy_failover", async () => {
+ const fetchMock = vi.fn(async () =>
+ sseResponse([
+ JSON.stringify({ choices: [{ delta: { content: "hello" } }] }),
+ JSON.stringify({ choices: [{ finish_reason: "stop" }] }),
+ ])
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const provider = new FailoverProvider(config);
+ const events: StreamEvent[] = [];
+ await provider.streamChat(request(), (e) => events.push(e));
+
+ expect(collectDeltas(events)).toBe("hello");
+ expect(provider.getLastRoute()).toMatch(/^proxy\//);
+ expect(fetchMock).toHaveBeenCalledOnce();
+ });
+
+ it("surfaces attempted tiers when every enabled tier fails (R40)", async () => {
+ const fetchMock = vi.fn(async () => new Response("upstream boom", { status: 500 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ const provider = new FailoverProvider(config);
+ await expect(provider.streamChat(request(), () => {})).rejects.toMatchObject({
+ message: expect.stringContaining("proxy_failover"),
+ });
+ });
+
+ it("searxng discovery suggests links then chain falls through to proxy (R39/R43)", async () => {
+ const { STORAGE_KEYS, saveJson } = await import("../storage-keys");
+ saveJson(STORAGE_KEYS.providerTiers, {
+ tiers: [
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: false },
+ { id: "searxng_discovery", enabled: true },
+ { id: "proxy_failover", enabled: true },
+ ],
+ webRunnerUrl: "",
+ searxngUrl: "http://searx.test",
+ });
+
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
+ const url = String(input);
+ if (url.startsWith("http://searx.test")) {
+ return new Response(
+ JSON.stringify({
+ results: [
+ { url: "https://chat.example.com", title: "Example AI chat", content: "free chat" },
+ ],
+ }),
+ { status: 200 }
+ );
+ }
+ return sseResponse([
+ JSON.stringify({ choices: [{ delta: { content: "proxy answer" } }] }),
+ JSON.stringify({ choices: [{ finish_reason: "stop" }] }),
+ ]);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ const provider = new FailoverProvider(config);
+ const events: StreamEvent[] = [];
+ await provider.streamChat(request(), (e) => events.push(e));
+
+ expect(collectDeltas(events)).toBe("proxy answer");
+ expect(provider.getLastRoute()).toMatch(/^proxy\//);
+ const urls = fetchMock.mock.calls.map((c) => String(c[0]));
+ expect(urls.some((u) => u.startsWith("http://searx.test/search"))).toBe(true);
+ });
+
+ it("blocks an image attachment on a non-vision model (R29)", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+
+ const provider = new FailoverProvider(config);
+ provider.setCatalog([{ id: "text/model", supports_vision: false }], {});
+
+ await expect(
+ provider.streamChat(imageRequest("text/model"), () => {})
+ ).rejects.toMatchObject({ kind: "vision_unsupported" });
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("sends an image_url part to the proxy for a vision model (R28)", async () => {
+ const fetchMock = vi.fn(async () =>
+ sseResponse([
+ JSON.stringify({ choices: [{ delta: { content: "a cat" } }] }),
+ JSON.stringify({ choices: [{ finish_reason: "stop" }] }),
+ ])
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const provider = new FailoverProvider(config);
+ provider.setCatalog([{ id: "vision/model", supports_vision: true }], {});
+
+ const events: StreamEvent[] = [];
+ await provider.streamChat(imageRequest("vision/model"), (e) => events.push(e));
+
+ expect(collectDeltas(events)).toBe("a cat");
+ const sent = JSON.parse((fetchMock.mock.calls[0][1] as RequestInit).body as string);
+ const userContent = sent.messages.at(-1).content;
+ expect(Array.isArray(userContent)).toBe(true);
+ expect(userContent).toContainEqual({
+ type: "image_url",
+ image_url: { url: PNG_DATA_URL },
+ });
+ });
+});
diff --git a/webui/src/providers/FailoverProvider.ts b/webui/src/providers/FailoverProvider.ts
index 2605664..707afe8 100644
--- a/webui/src/providers/FailoverProvider.ts
+++ b/webui/src/providers/FailoverProvider.ts
@@ -1,6 +1,6 @@
import { trackChatCompletionSuccess } from "../analytics";
import { getActiveModel } from "../model-selection";
-import type { ChatProvider, ChatRequest, Message, StreamEvent } from "murm-ui";
+import type { ChatProvider, ChatRequest, StreamEvent } from "murm-ui";
import type { AppConfig } from "../config";
import { readRuntimeConfig } from "../config";
import type { CatalogEntry } from "./browser-router";
@@ -8,14 +8,32 @@ import {
RETRYABLE,
chatWithBrowserFallback,
loadKeys,
- shouldFallbackToProxy,
shouldTryBrowser,
} from "./browser-router";
import { ensureTurnstileToken } from "../turnstile-session";
import { showRateLimitBanner } from "../plugins/status-strip";
import { ChatRouteError, mapHttpError, mapProxyChainFailure, type RateLimitInfo, type RateLimitScope } from "./errors";
+import {
+ messagesHaveImage,
+ messagesToOpenAi,
+ messagesToPlainText,
+ modelSupportsVision,
+} from "./message-openai";
import { setLastCompletionMeta, type CompletionMeta } from "./routing-metadata";
import { emitOpenAiSseAsStreamEvents, emitTextAsStreamEvents } from "./sse";
+import {
+ TierOrchestrator,
+ qualityApiTierUnavailable,
+ searxngTierUnavailable,
+ webUiTierUnavailable,
+} from "./tiers/orchestrator";
+import {
+ broadcastDiscoveryResults,
+ searchFreeChatCandidates,
+} from "./tiers/searxng-discovery-tier";
+import { loadProviderTierSettings } from "./tiers/settings";
+import { TierOrchestratorError, TierSkipError } from "./tiers/types";
+import { streamFromWebRunner } from "./tiers/web-ui-tier";
type StatusListener = (status: string) => void;
@@ -26,16 +44,6 @@ function endpointUrl(base: string): string {
: `${trimmed}/v1/chat/completions`;
}
-function messagesToOpenAi(messages: readonly Message[]): { role: string; content: string }[] {
- return messages.map((m) => {
- const text = m.blocks
- .filter((b) => b.type === "text")
- .map((b) => (b.type === "text" ? b.text : ""))
- .join("");
- return { role: m.role, content: text };
- });
-}
-
function readRoutingHeaders(res: Response): Pick {
const modelHeader =
res.headers.get("x-litellm-model-name") ||
@@ -240,92 +248,189 @@ export class FailoverProvider implements ChatProvider {
throw mapProxyChainFailure(lastError, lastRateLimit);
}
- async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise {
+ private buildChatBody(request: ChatRequest): {
+ config: AppConfig;
+ model: string;
+ body: Record;
+ plainMessages: { role: string; content: string }[];
+ hasImage: boolean;
+ } {
const config = this.getRuntimeConfig();
const model = this.resolveModel(request);
- const openAiMessages = messagesToOpenAi(request.messages);
+ // Proxy body carries multimodal content parts; browser/BYOK routes use the
+ // text-only projection (they cannot forward inline images yet).
const body = {
model,
- messages: openAiMessages,
+ messages: messagesToOpenAi(request.messages),
max_tokens: request.options.max_tokens ?? config.maxTokens,
};
-
- const keys = loadKeys();
- const userKeys = keys;
-
- const tryBrowser = async (onEv: (event: StreamEvent) => void): Promise => {
- const result = await chatWithBrowserFallback({
- model,
- messages: openAiMessages,
- maxTokens: body.max_tokens as number,
- catalog: this.catalog,
- providerUrls: this.providerUrls,
- keys: userKeys,
- onStatus: (s) => this.setStatus(s),
- });
- this.lastRoute = result.route;
- window.LLM_FALLBACKS_ROUTE = result.route;
- setLastCompletionMeta({
- endpoint: result.route,
- fallbackCount: 0,
- });
- emitTextAsStreamEvents(result.content, onEv);
+ return {
+ config,
+ model,
+ body,
+ plainMessages: messagesToPlainText(request.messages),
+ hasImage: messagesHaveImage(request.messages),
};
+ }
- if (config.endpoints.length) {
- try {
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback(body, config, onEv, request.signal),
- onEvent
- );
- return;
- } catch (proxyErr) {
- if (!shouldTryBrowser(model, this.catalog, userKeys)) throw proxyErr;
- this.setStatus("cloud proxy unavailable — trying optional browser route …");
- }
+ // quality_api tier: direct/BYOK provider routes only. Disjoint from
+ // proxy_failover (KTD7) — this tier never touches the cloud proxy chain, so
+ // a single request is not attempted twice against the same endpoint. Without
+ // a usable key it skips, letting the orchestrator advance to proxy_failover.
+ private async streamQualityApiRoute(
+ request: ChatRequest,
+ onEvent: (event: StreamEvent) => void
+ ): Promise {
+ const { model, body, plainMessages, hasImage } = this.buildChatBody(request);
+ const userKeys = loadKeys();
+
+ // Direct BYOK routes cannot forward inline images yet — skip so the
+ // orchestrator advances to a proxy tier rather than dropping the image.
+ if (hasImage) {
+ throw new TierSkipError(
+ "quality_api",
+ "Direct BYOK routes do not support image attachments yet — using the proxy tier."
+ );
}
- if (model !== "free" && !shouldTryBrowser(model, this.catalog, userKeys)) {
- if (config.endpoints.length) {
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback({ ...body, model: "free" }, config, onEv, request.signal),
- onEvent
+ if (!shouldTryBrowser(model, this.catalog, userKeys)) {
+ throw qualityApiTierUnavailable();
+ }
+
+ const result = await chatWithBrowserFallback({
+ model,
+ messages: plainMessages,
+ maxTokens: body.max_tokens as number,
+ catalog: this.catalog,
+ providerUrls: this.providerUrls,
+ keys: userKeys,
+ onStatus: (s) => this.setStatus(s),
+ });
+ this.lastRoute = result.route;
+ window.LLM_FALLBACKS_ROUTE = this.lastRoute;
+ setLastCompletionMeta({
+ endpoint: result.route,
+ fallbackCount: 0,
+ });
+ emitTextAsStreamEvents(result.content, onEvent);
+ }
+
+ private async streamProxyFailoverRoute(
+ request: ChatRequest,
+ onEvent: (event: StreamEvent) => void
+ ): Promise {
+ const { config, body } = this.buildChatBody(request);
+ await this.streamProxyFallback(body, config, onEvent, request.signal);
+ }
+
+ private async streamWebUiRoute(
+ request: ChatRequest,
+ onEvent: (event: StreamEvent) => void
+ ): Promise {
+ const settings = loadProviderTierSettings();
+ if (!settings.webRunnerUrl) {
+ throw webUiTierUnavailable();
+ }
+ const { model, body, plainMessages, hasImage } = this.buildChatBody(request);
+ if (hasImage) {
+ throw new TierSkipError(
+ "web_ui",
+ "The web runner does not support image attachments — using the proxy tier."
+ );
+ }
+ this.setStatus(`web runner: ${settings.webRunnerUrl} …`);
+ let metaSet = false;
+ await streamFromWebRunner({
+ runnerUrl: settings.webRunnerUrl,
+ model,
+ messages: plainMessages,
+ maxTokens: body.max_tokens as number,
+ signal: request.signal,
+ onEvent: (event) => {
+ // Record the route once the runner actually starts streaming, so a
+ // pre-stream failure never leaves stale web_ui routing metadata.
+ if (!metaSet) {
+ metaSet = true;
+ this.lastRoute = `web_ui/${settings.webRunnerUrl}`;
+ window.LLM_FALLBACKS_ROUTE = this.lastRoute;
+ setLastCompletionMeta({ endpoint: this.lastRoute, fallbackCount: 0 });
+ }
+ onEvent(event);
+ },
+ });
+ }
+
+ private async streamSearxngDiscoveryRoute(
+ request: ChatRequest,
+ _onEvent: (event: StreamEvent) => void
+ ): Promise {
+ const settings = loadProviderTierSettings();
+ if (!settings.searxngUrl) {
+ throw searxngTierUnavailable();
+ }
+ this.setStatus("searxng: searching for free chat sites …");
+ const candidates = await searchFreeChatCandidates({
+ searxngUrl: settings.searxngUrl,
+ signal: request.signal,
+ });
+ broadcastDiscoveryResults(candidates);
+ // Discovery suggests links (R39) — it cannot answer the prompt itself.
+ // Record a descriptive attempt so the orchestrator moves to the next tier.
+ throw new Error(
+ `SearXNG found ${candidates.length} candidate chat site${
+ candidates.length === 1 ? "" : "s"
+ } — see suggestions below the chat.`
+ );
+ }
+
+ async streamChat(request: ChatRequest, onEvent: (event: StreamEvent) => void): Promise {
+ // Vision guard (R29): never silently drop attachments. If the turn carries
+ // an image but the selected model is not vision-capable, block clearly and
+ // point at the vision-badged models in the explorer (R30).
+ if (messagesHaveImage(request.messages)) {
+ const model = this.resolveModel(request);
+ if (!modelSupportsVision(model, this.catalog)) {
+ throw new ChatRouteError(
+ "vision_unsupported",
+ `"${model}" can't read images. Pick a vision-capable model (filter by vision in the model explorer) or remove the attachment.`
);
- return;
}
- throw new Error(
- "Selected model requires an API key for its provider. Choose free or add the provider key in Settings."
- );
}
- if (shouldTryBrowser(model, this.catalog, userKeys)) {
- try {
- await this.streamWithCompletionTracking((onEv) => tryBrowser(onEv), onEvent);
- return;
- } catch (browserErr) {
- const err = browserErr instanceof Error ? browserErr : new Error(String(browserErr));
- if (config.endpoints.length && shouldFallbackToProxy(err)) {
- this.setStatus("browser route failed — retrying cloud proxy …");
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback(body, config, onEv, request.signal),
- onEvent
- );
- return;
- }
- throw err;
+ const orchestrator = new TierOrchestrator({
+ qualityApi: (req, onEv) =>
+ this.streamWithCompletionTracking((inner) => this.streamQualityApiRoute(req, inner), onEv),
+ webUi: (req, onEv) =>
+ this.streamWithCompletionTracking((inner) => this.streamWebUiRoute(req, inner), onEv),
+ searxngDiscovery: (req, onEv) =>
+ this.streamWithCompletionTracking(
+ (inner) => this.streamSearxngDiscoveryRoute(req, inner),
+ onEv
+ ),
+ proxyFailover: (req, onEv) =>
+ this.streamWithCompletionTracking((inner) => this.streamProxyFailoverRoute(req, inner), onEv),
+ });
+
+ try {
+ await orchestrator.streamChat(request, onEvent);
+ } catch (err) {
+ if (err instanceof TierOrchestratorError) {
+ throw this.mapTierFailure(err);
}
+ throw err;
}
+ }
- if (config.endpoints.length) {
- await this.streamWithCompletionTracking(
- (onEv) => this.streamProxyFallback(body, config, onEv, request.signal),
- onEvent
+ // Preserve the ChatRouteError taxonomy (rate-limit / quota / cold-start) while
+ // surfacing which tiers were tried and their last error (R40). A no-attempt
+ // failure means every tier skipped or none were enabled.
+ private mapTierFailure(err: TierOrchestratorError): Error {
+ if (err.attempts.length === 0) {
+ return mapProxyChainFailure(
+ "No chat routes are available yet. Enable a provider tier in Settings, or wait for the demo proxy to finish deploying."
);
- return;
}
-
- throw mapProxyChainFailure(
- "No chat routes are available yet. The demo proxy is still deploying — refresh in a minute."
- );
+ const summary = err.attempts.map((a) => `${a.tier} → ${a.error}`).join(" | ");
+ return mapProxyChainFailure(summary);
}
}
diff --git a/webui/src/providers/message-openai.test.ts b/webui/src/providers/message-openai.test.ts
new file mode 100644
index 0000000..533584d
--- /dev/null
+++ b/webui/src/providers/message-openai.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import type { Message } from "murm-ui";
+import type { CatalogEntry } from "./browser-router";
+import {
+ getVisionCatalogModels,
+ messagesHaveImage,
+ messagesToOpenAi,
+ messagesToPlainText,
+ modelSupportsVision,
+} from "./message-openai";
+
+const PNG_DATA_URL = "data:image/png;base64,iVBORw0KGgoAAAANS";
+
+function textMessage(role: Message["role"], text: string): Message {
+ return { id: `${role}-1`, role, blocks: [{ id: "b1", type: "text", text }] };
+}
+
+function imageMessage(text: string): Message {
+ return {
+ id: "user-img",
+ role: "user",
+ blocks: [
+ { id: "t1", type: "text", text },
+ { id: "f1", type: "file", mimeType: "image/png", name: "shot.png", data: PNG_DATA_URL },
+ ],
+ };
+}
+
+const catalog: CatalogEntry[] = [
+ { id: "vision/model", supports_vision: true },
+ { id: "text/model", supports_vision: false },
+];
+
+describe("messagesToOpenAi", () => {
+ it("keeps text-only turns as string content", () => {
+ const result = messagesToOpenAi([textMessage("user", "hello")]);
+ expect(result).toEqual([{ role: "user", content: "hello" }]);
+ });
+
+ it("maps an image file block to an image_url content part", () => {
+ const result = messagesToOpenAi([imageMessage("describe this")]);
+ expect(result).toHaveLength(1);
+ const content = result[0].content;
+ expect(Array.isArray(content)).toBe(true);
+ const parts = content as { type: string }[];
+ expect(parts).toContainEqual({ type: "text", text: "describe this" });
+ expect(parts).toContainEqual({ type: "image_url", image_url: { url: PNG_DATA_URL } });
+ });
+
+ it("omits the text part when the turn is image-only", () => {
+ const result = messagesToOpenAi([
+ { id: "u", role: "user", blocks: [{ id: "f", type: "file", mimeType: "image/png", data: PNG_DATA_URL }] },
+ ]);
+ const parts = result[0].content as { type: string }[];
+ expect(parts).toEqual([{ type: "image_url", image_url: { url: PNG_DATA_URL } }]);
+ });
+});
+
+describe("messagesToPlainText", () => {
+ it("drops image blocks and keeps text", () => {
+ const result = messagesToPlainText([imageMessage("caption")]);
+ expect(result).toEqual([{ role: "user", content: "caption" }]);
+ });
+});
+
+describe("image + vision helpers", () => {
+ it("detects image attachments", () => {
+ expect(messagesHaveImage([imageMessage("x")])).toBe(true);
+ expect(messagesHaveImage([textMessage("user", "x")])).toBe(false);
+ });
+
+ it("resolves vision capability from the catalog", () => {
+ expect(modelSupportsVision("vision/model", catalog)).toBe(true);
+ expect(modelSupportsVision("text/model", catalog)).toBe(false);
+ // Unknown / alias models (e.g. "free") are treated as non-vision.
+ expect(modelSupportsVision("free", catalog)).toBe(false);
+ });
+
+ it("lists vision-capable catalog models (R30)", () => {
+ expect(getVisionCatalogModels(catalog).map((e) => e.id)).toEqual(["vision/model"]);
+ });
+});
diff --git a/webui/src/providers/message-openai.ts b/webui/src/providers/message-openai.ts
new file mode 100644
index 0000000..abc70a0
--- /dev/null
+++ b/webui/src/providers/message-openai.ts
@@ -0,0 +1,82 @@
+import type { Message } from "murm-ui";
+import type { CatalogEntry } from "./browser-router";
+
+// OpenAI-compatible chat content. A string for text-only turns; an array of
+// parts for multimodal turns (text + inline image data URLs). LiteLLM / the
+// proxy accept `image_url` parts when the resolved model supports vision.
+export type OpenAiContentPart =
+ | { type: "text"; text: string }
+ | { type: "image_url"; image_url: { url: string } };
+
+export interface OpenAiMessage {
+ role: string;
+ content: string | OpenAiContentPart[];
+}
+
+interface ImageFileBlock {
+ type: "file";
+ mimeType: string;
+ name?: string;
+ data: string;
+}
+
+function isImageFileBlock(block: { type: string }): block is ImageFileBlock {
+ return (
+ block.type === "file" &&
+ typeof (block as ImageFileBlock).mimeType === "string" &&
+ (block as ImageFileBlock).mimeType.startsWith("image/") &&
+ typeof (block as ImageFileBlock).data === "string"
+ );
+}
+
+function messageText(message: Message): string {
+ return message.blocks
+ .filter((b) => b.type === "text")
+ .map((b) => (b.type === "text" ? b.text : ""))
+ .join("");
+}
+
+export function messageHasImage(message: Message): boolean {
+ return message.blocks.some((b) => isImageFileBlock(b));
+}
+
+export function messagesHaveImage(messages: readonly Message[]): boolean {
+ return messages.some((m) => messageHasImage(m));
+}
+
+// Multimodal projection: text-only turns stay strings; turns with image file
+// blocks become an OpenAI content-part array so images are never dropped.
+export function messagesToOpenAi(messages: readonly Message[]): OpenAiMessage[] {
+ return messages.map((message) => {
+ const text = messageText(message);
+ const images = message.blocks.filter((b): b is ImageFileBlock => isImageFileBlock(b));
+ if (images.length === 0) {
+ return { role: message.role, content: text };
+ }
+ const parts: OpenAiContentPart[] = [];
+ if (text) parts.push({ type: "text", text });
+ for (const image of images) {
+ parts.push({ type: "image_url", image_url: { url: image.data } });
+ }
+ return { role: message.role, content: parts };
+ });
+}
+
+// Text-only projection for routes that cannot carry inline images yet
+// (browser/BYOK direct calls). Image blocks are omitted here on purpose; the
+// vision guard and quality_api image skip ensure such turns never reach a
+// route that would silently drop the attachment.
+export function messagesToPlainText(
+ messages: readonly Message[]
+): { role: string; content: string }[] {
+ return messages.map((message) => ({ role: message.role, content: messageText(message) }));
+}
+
+export function modelSupportsVision(modelId: string, catalog: readonly CatalogEntry[]): boolean {
+ const entry = catalog.find((e) => e.id === modelId);
+ return entry?.supports_vision === true;
+}
+
+export function getVisionCatalogModels(catalog: readonly CatalogEntry[]): CatalogEntry[] {
+ return catalog.filter((e) => e.supports_vision === true);
+}
diff --git a/webui/src/providers/tiers/defaults.test.ts b/webui/src/providers/tiers/defaults.test.ts
new file mode 100644
index 0000000..b148a0f
--- /dev/null
+++ b/webui/src/providers/tiers/defaults.test.ts
@@ -0,0 +1,75 @@
+import { describe, expect, it } from "vitest";
+import { defaultProviderTierSettings, normalizeTierSettings } from "./defaults";
+import type { ProviderTierSettings } from "./types";
+
+describe("tier defaults", () => {
+ it("enables quality_api and proxy_failover by default (zero-config routing)", () => {
+ const settings = defaultProviderTierSettings();
+ const enabled = settings.tiers.filter((t) => t.enabled).map((t) => t.id);
+ expect(enabled).toContain("quality_api");
+ expect(enabled).toContain("proxy_failover");
+ expect(enabled).not.toContain("web_ui");
+ expect(enabled).not.toContain("searxng_discovery");
+ });
+});
+
+describe("normalizeTierSettings", () => {
+ it("preserves the user's saved tier order", () => {
+ const raw: ProviderTierSettings = {
+ tiers: [
+ { id: "proxy_failover", enabled: true },
+ { id: "quality_api", enabled: false },
+ { id: "searxng_discovery", enabled: true },
+ { id: "web_ui", enabled: false },
+ ],
+ webRunnerUrl: "",
+ searxngUrl: "",
+ };
+ const normalized = normalizeTierSettings(raw);
+ expect(normalized.tiers.map((t) => t.id)).toEqual([
+ "proxy_failover",
+ "quality_api",
+ "searxng_discovery",
+ "web_ui",
+ ]);
+ expect(normalized.tiers[0].enabled).toBe(true);
+ expect(normalized.tiers[1].enabled).toBe(false);
+ });
+
+ it("appends omitted tiers at their default-enabled state", () => {
+ const raw: ProviderTierSettings = {
+ tiers: [{ id: "proxy_failover", enabled: false }],
+ webRunnerUrl: "",
+ searxngUrl: "",
+ };
+ const normalized = normalizeTierSettings(raw);
+ expect(normalized.tiers.map((t) => t.id)).toEqual([
+ "proxy_failover",
+ "quality_api",
+ "web_ui",
+ "searxng_discovery",
+ ]);
+ // Explicit stored value wins over the default.
+ expect(normalized.tiers.find((t) => t.id === "proxy_failover")?.enabled).toBe(false);
+ // Omitted quality_api falls back to its default-enabled state.
+ expect(normalized.tiers.find((t) => t.id === "quality_api")?.enabled).toBe(true);
+ });
+
+ it("dedupes and drops unknown tier ids", () => {
+ const raw = {
+ tiers: [
+ { id: "quality_api", enabled: true },
+ { id: "quality_api", enabled: false },
+ { id: "bogus_tier", enabled: true },
+ ],
+ webRunnerUrl: "",
+ searxngUrl: "",
+ } as unknown as ProviderTierSettings;
+ const normalized = normalizeTierSettings(raw);
+ const ids = normalized.tiers.map((t) => t.id);
+ expect(ids.filter((id) => id === "quality_api")).toHaveLength(1);
+ expect(ids).not.toContain("bogus_tier");
+ // First occurrence wins.
+ expect(normalized.tiers.find((t) => t.id === "quality_api")?.enabled).toBe(true);
+ });
+});
diff --git a/webui/src/providers/tiers/defaults.ts b/webui/src/providers/tiers/defaults.ts
new file mode 100644
index 0000000..f7cbf1e
--- /dev/null
+++ b/webui/src/providers/tiers/defaults.ts
@@ -0,0 +1,52 @@
+import type { ProviderTierSettings, TierEntry, TierId } from "./types";
+
+export const TIER_IDS: TierId[] = [
+ "quality_api",
+ "web_ui",
+ "searxng_discovery",
+ "proxy_failover",
+];
+
+// Zero-config default: BYOK direct routes first (when keys exist), then the
+// public proxy chain. quality_api skips instantly without keys, so a keyless
+// visitor is served by proxy_failover — matching pre-4B proxy-first behavior.
+export const DEFAULT_TIER_ENTRIES: TierEntry[] = [
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: false },
+ { id: "searxng_discovery", enabled: false },
+ { id: "proxy_failover", enabled: true },
+];
+
+const DEFAULT_ENABLED_BY_ID = new Map(
+ DEFAULT_TIER_ENTRIES.map((t) => [t.id, t.enabled])
+);
+
+export function defaultProviderTierSettings(): ProviderTierSettings {
+ return {
+ tiers: DEFAULT_TIER_ENTRIES.map((t) => ({ ...t })),
+ webRunnerUrl: "",
+ searxngUrl: "",
+ };
+}
+
+// Preserve the user's saved tier order (R36) — dedupe and drop unknown ids,
+// then append any tier the stored settings omitted at its default-enabled
+// state. Rebuilding from a fixed TIER_IDS order would silently discard the
+// ordering the settings panel writes.
+export function normalizeTierSettings(raw: ProviderTierSettings): ProviderTierSettings {
+ const seen = new Set();
+ const tiers: TierEntry[] = [];
+ for (const entry of raw.tiers ?? []) {
+ if (!DEFAULT_ENABLED_BY_ID.has(entry.id) || seen.has(entry.id)) continue;
+ seen.add(entry.id);
+ tiers.push({ id: entry.id, enabled: !!entry.enabled });
+ }
+ for (const id of TIER_IDS) {
+ if (!seen.has(id)) tiers.push({ id, enabled: DEFAULT_ENABLED_BY_ID.get(id) ?? false });
+ }
+ return {
+ tiers,
+ webRunnerUrl: raw.webRunnerUrl?.trim() ?? "",
+ searxngUrl: raw.searxngUrl?.trim() ?? "",
+ };
+}
diff --git a/webui/src/providers/tiers/orchestrator.test.ts b/webui/src/providers/tiers/orchestrator.test.ts
new file mode 100644
index 0000000..ad918fe
--- /dev/null
+++ b/webui/src/providers/tiers/orchestrator.test.ts
@@ -0,0 +1,180 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { ChatRequest, StreamEvent } from "murm-ui";
+import { STORAGE_KEYS, saveJson } from "../../storage-keys";
+import { defaultProviderTierSettings } from "./defaults";
+import {
+ TierOrchestrator,
+ searxngTierUnavailable,
+ webUiTierUnavailable,
+} from "./orchestrator";
+import { TierOrchestratorError, TierSkipError } from "./types";
+
+const baseRequest: ChatRequest = {
+ messages: [{ id: "1", role: "user", blocks: [{ type: "text", text: "hi" }] }],
+ options: {},
+ signal: new AbortController().signal,
+};
+
+describe("TierOrchestrator", () => {
+ beforeEach(() => {
+ localStorage.clear();
+ });
+
+ it("runs enabled tiers in saved order (quality_api before proxy_failover)", async () => {
+ saveJson(STORAGE_KEYS.providerTiers, defaultProviderTierSettings());
+ const calls: string[] = [];
+ const qualityApi = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => {
+ calls.push("quality_api");
+ onEvent({ type: "text_delta", delta: "ok" });
+ });
+ const proxyFailover = vi.fn(async () => {
+ calls.push("proxy_failover");
+ });
+ const orchestrator = new TierOrchestrator({
+ qualityApi,
+ webUi: vi.fn(),
+ searxngDiscovery: vi.fn(),
+ proxyFailover,
+ });
+
+ await orchestrator.streamChat(baseRequest, () => {});
+
+ expect(qualityApi).toHaveBeenCalledOnce();
+ expect(proxyFailover).not.toHaveBeenCalled();
+ expect(calls).toEqual(["quality_api"]);
+ });
+
+ it("honors a reordered tier list (proxy_failover first)", async () => {
+ saveJson(STORAGE_KEYS.providerTiers, {
+ ...defaultProviderTierSettings(),
+ tiers: [
+ { id: "proxy_failover", enabled: true },
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: false },
+ { id: "searxng_discovery", enabled: false },
+ ],
+ });
+ const qualityApi = vi.fn();
+ const proxyFailover = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => {
+ onEvent({ type: "text_delta", delta: "proxy first" });
+ });
+ const orchestrator = new TierOrchestrator({
+ qualityApi,
+ webUi: vi.fn(),
+ searxngDiscovery: vi.fn(),
+ proxyFailover,
+ });
+
+ await orchestrator.streamChat(baseRequest, () => {});
+
+ expect(proxyFailover).toHaveBeenCalledOnce();
+ expect(qualityApi).not.toHaveBeenCalled();
+ });
+
+ it("skips disabled web_ui tier", async () => {
+ saveJson(STORAGE_KEYS.providerTiers, defaultProviderTierSettings());
+ const webUi = vi.fn(async () => {
+ throw webUiTierUnavailable();
+ });
+ const qualityApi = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => {
+ onEvent({ type: "text_delta", delta: "ok" });
+ });
+ const orchestrator = new TierOrchestrator({
+ qualityApi,
+ webUi,
+ searxngDiscovery: vi.fn(),
+ proxyFailover: vi.fn(),
+ });
+
+ await orchestrator.streamChat(baseRequest, () => {});
+
+ expect(webUi).not.toHaveBeenCalled();
+ });
+
+ it("falls through to proxy_failover when quality_api fails", async () => {
+ saveJson(STORAGE_KEYS.providerTiers, {
+ ...defaultProviderTierSettings(),
+ tiers: [
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: false },
+ { id: "searxng_discovery", enabled: false },
+ { id: "proxy_failover", enabled: true },
+ ],
+ });
+ const qualityApi = vi.fn(async () => {
+ throw new Error("api down");
+ });
+ const proxyFailover = vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => {
+ onEvent({ type: "text_delta", delta: "proxy ok" });
+ });
+ const orchestrator = new TierOrchestrator({
+ qualityApi,
+ webUi: vi.fn(),
+ searxngDiscovery: vi.fn(),
+ proxyFailover,
+ });
+
+ await orchestrator.streamChat(baseRequest, () => {});
+
+ expect(proxyFailover).toHaveBeenCalledOnce();
+ });
+
+ it("throws TierOrchestratorError with attempt list when all tiers fail", async () => {
+ saveJson(STORAGE_KEYS.providerTiers, {
+ ...defaultProviderTierSettings(),
+ tiers: [
+ { id: "quality_api", enabled: true },
+ { id: "web_ui", enabled: true },
+ { id: "searxng_discovery", enabled: false },
+ { id: "proxy_failover", enabled: false },
+ ],
+ webRunnerUrl: "",
+ });
+ const orchestrator = new TierOrchestrator({
+ qualityApi: vi.fn(async () => {
+ throw new Error("api fail");
+ }),
+ webUi: vi.fn(async () => {
+ throw webUiTierUnavailable();
+ }),
+ searxngDiscovery: vi.fn(),
+ proxyFailover: vi.fn(),
+ });
+
+ await expect(orchestrator.streamChat(baseRequest, () => {})).rejects.toBeInstanceOf(
+ TierOrchestratorError
+ );
+
+ try {
+ await orchestrator.streamChat(baseRequest, () => {});
+ } catch (err) {
+ const orchestratorErr = err as TierOrchestratorError;
+ expect(orchestratorErr.attempts.length).toBeGreaterThanOrEqual(2);
+ expect(orchestratorErr.attempts[0].tier).toBe("quality_api");
+ }
+ });
+
+ it("TierSkipError is recorded but does not abort the chain", async () => {
+ saveJson(STORAGE_KEYS.providerTiers, {
+ ...defaultProviderTierSettings(),
+ tiers: [
+ { id: "quality_api", enabled: false },
+ { id: "web_ui", enabled: true },
+ { id: "searxng_discovery", enabled: false },
+ { id: "proxy_failover", enabled: true },
+ ],
+ });
+ const orchestrator = new TierOrchestrator({
+ qualityApi: vi.fn(),
+ webUi: vi.fn(async () => {
+ throw new TierSkipError("web_ui", "no runner url");
+ }),
+ searxngDiscovery: vi.fn(),
+ proxyFailover: vi.fn(async (_req, onEvent: (e: StreamEvent) => void) => {
+ onEvent({ type: "text_delta", delta: "ok" });
+ }),
+ });
+
+ await orchestrator.streamChat(baseRequest, () => {});
+ });
+});
diff --git a/webui/src/providers/tiers/orchestrator.ts b/webui/src/providers/tiers/orchestrator.ts
new file mode 100644
index 0000000..0bd4b46
--- /dev/null
+++ b/webui/src/providers/tiers/orchestrator.ts
@@ -0,0 +1,86 @@
+import type { ChatRequest, StreamEvent } from "murm-ui";
+import { loadProviderTierSettings } from "./settings";
+import type { TierAttempt, TierId } from "./types";
+import { TierOrchestratorError, TierSkipError } from "./types";
+
+export interface TierHandlers {
+ qualityApi: (request: ChatRequest, onEvent: (event: StreamEvent) => void) => Promise;
+ webUi: (request: ChatRequest, onEvent: (event: StreamEvent) => void) => Promise;
+ searxngDiscovery: (
+ request: ChatRequest,
+ onEvent: (event: StreamEvent) => void
+ ) => Promise;
+ proxyFailover: (request: ChatRequest, onEvent: (event: StreamEvent) => void) => Promise;
+}
+
+function formatAttemptError(err: unknown): string {
+ if (err instanceof Error) return err.message;
+ return String(err);
+}
+
+export class TierOrchestrator {
+ constructor(private readonly handlers: TierHandlers) {}
+
+ async streamChat(
+ request: ChatRequest,
+ onEvent: (event: StreamEvent) => void
+ ): Promise {
+ const settings = loadProviderTierSettings();
+ const attempts: TierAttempt[] = [];
+
+ for (const entry of settings.tiers) {
+ if (!entry.enabled) continue;
+
+ const handler = this.handlerFor(entry.id);
+ if (!handler) continue;
+
+ try {
+ await handler(request, onEvent);
+ return;
+ } catch (err) {
+ if (err instanceof TierSkipError) {
+ attempts.push({ tier: entry.id, error: err.message });
+ continue;
+ }
+ attempts.push({ tier: entry.id, error: formatAttemptError(err) });
+ if (request.signal.aborted) throw err;
+ }
+ }
+
+ const summary = attempts.map((a) => `${a.tier}: ${a.error}`).join("; ");
+ throw new TierOrchestratorError(
+ summary || "No provider tiers are enabled.",
+ attempts
+ );
+ }
+
+ private handlerFor(id: TierId): TierHandlers[keyof TierHandlers] | null {
+ switch (id) {
+ case "quality_api":
+ return this.handlers.qualityApi;
+ case "web_ui":
+ return this.handlers.webUi;
+ case "searxng_discovery":
+ return this.handlers.searxngDiscovery;
+ case "proxy_failover":
+ return this.handlers.proxyFailover;
+ default:
+ return null;
+ }
+ }
+}
+
+export function qualityApiTierUnavailable(): TierSkipError {
+ return new TierSkipError(
+ "quality_api",
+ "No BYOK API key set for the selected model — skipping direct routes."
+ );
+}
+
+export function webUiTierUnavailable(): TierSkipError {
+ return new TierSkipError("web_ui", "Web UI tier is not configured.");
+}
+
+export function searxngTierUnavailable(): TierSkipError {
+ return new TierSkipError("searxng_discovery", "SearXNG discovery is not configured.");
+}
diff --git a/webui/src/providers/tiers/searxng-discovery-tier.test.ts b/webui/src/providers/tiers/searxng-discovery-tier.test.ts
new file mode 100644
index 0000000..18be587
--- /dev/null
+++ b/webui/src/providers/tiers/searxng-discovery-tier.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it, vi } from "vitest";
+import {
+ DEFAULT_DISCOVERY_QUERY,
+ DiscoveryEmptyError,
+ DiscoveryUnavailableError,
+ discoverySearchUrl,
+ filterChatCandidates,
+ searchFreeChatCandidates,
+} from "./searxng-discovery-tier";
+
+const SEARX = "http://127.0.0.1:8080";
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("filterChatCandidates", () => {
+ it("keeps https chat-looking results, one per host, capped", () => {
+ const results = [
+ { url: "https://chat.example.com", title: "Example AI Chat", content: "free chat" },
+ { url: "https://chat.example.com/other", title: "Same host again", content: "chat" },
+ { url: "http://insecure.example.org", title: "AI chat", content: "chat" },
+ { url: "https://en.wikipedia.org/wiki/Chatbot", title: "Chatbot", content: "chat" },
+ { url: "https://plain.example.net", title: "Cooking recipes", content: "food" },
+ { url: "https://gpt.example.io", title: "Free GPT playground", content: "" },
+ ];
+ const candidates = filterChatCandidates(results);
+ expect(candidates.map((c) => c.url)).toEqual([
+ "https://chat.example.com",
+ "https://gpt.example.io",
+ ]);
+ });
+});
+
+describe("searchFreeChatCandidates", () => {
+ it("returns ≥1 candidate from mock SearXNG JSON (AE4)", async () => {
+ const fetchImpl = vi.fn(async () =>
+ jsonResponse({
+ results: [
+ { url: "https://chat.example.com", title: "Example AI Chat", content: "no signup" },
+ ],
+ })
+ );
+ const candidates = await searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl });
+ expect(candidates).toHaveLength(1);
+ expect(candidates[0].url).toBe("https://chat.example.com");
+ expect(fetchImpl).toHaveBeenCalledWith(
+ discoverySearchUrl(SEARX, DEFAULT_DISCOVERY_QUERY),
+ expect.objectContaining({ headers: { Accept: "application/json" } })
+ );
+ });
+
+ it("throws a typed empty diagnostic when nothing matches (R40)", async () => {
+ const fetchImpl = vi.fn(async () => jsonResponse({ results: [] }));
+ await expect(
+ searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl })
+ ).rejects.toBeInstanceOf(DiscoveryEmptyError);
+ });
+
+ it("maps fetch failure (CORS/network) to a clear diagnostic", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new TypeError("Failed to fetch");
+ });
+ await expect(
+ searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl })
+ ).rejects.toMatchObject({
+ name: "DiscoveryUnavailableError",
+ message: expect.stringContaining("CORS"),
+ });
+ });
+
+ it("maps HTTP errors and non-JSON bodies to unavailable diagnostics", async () => {
+ const fetch403 = vi.fn(async () => new Response("forbidden", { status: 403 }));
+ await expect(
+ searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl: fetch403 })
+ ).rejects.toBeInstanceOf(DiscoveryUnavailableError);
+
+ const fetchHtml = vi.fn(async () => new Response("", { status: 200 }));
+ await expect(
+ searchFreeChatCandidates({ searxngUrl: SEARX, fetchImpl: fetchHtml })
+ ).rejects.toMatchObject({ message: expect.stringContaining("JSON format") });
+ });
+});
diff --git a/webui/src/providers/tiers/searxng-discovery-tier.ts b/webui/src/providers/tiers/searxng-discovery-tier.ts
new file mode 100644
index 0000000..dfa3be3
--- /dev/null
+++ b/webui/src/providers/tiers/searxng-discovery-tier.ts
@@ -0,0 +1,130 @@
+/**
+ * SearXNG discovery tier (R39): query a user-configured SearXNG instance for
+ * candidate free web chat URLs. Discovery never chats by itself — results are
+ * presented as a pick list (Q5) or fed to the opt-in web runner. Errors are
+ * typed so the orchestrator surfaces actionable diagnostics (R40).
+ */
+
+export interface DiscoveryCandidate {
+ url: string;
+ title: string;
+ snippet: string;
+}
+
+export class DiscoveryEmptyError extends Error {
+ constructor(query: string) {
+ super(`SearXNG returned no candidate free chat sites for "${query}".`);
+ this.name = "DiscoveryEmptyError";
+ }
+}
+
+export class DiscoveryUnavailableError extends Error {
+ constructor(endpoint: string, cause: string) {
+ super(
+ `SearXNG at ${endpoint} is unreachable (${cause}). ` +
+ "Check the URL in Tiers settings and that the instance allows browser requests (CORS)."
+ );
+ this.name = "DiscoveryUnavailableError";
+ }
+}
+
+export const DEFAULT_DISCOVERY_QUERY = "free AI chat online no signup";
+
+const CHAT_HINT_RE = /\b(chat|gpt|assistant|llm|ai)\b/i;
+const EXCLUDED_HOST_RE =
+ /(^|\.)(wikipedia\.org|youtube\.com|reddit\.com|github\.com|medium\.com|x\.com|twitter\.com|facebook\.com|linkedin\.com)$/i;
+const MAX_CANDIDATES = 6;
+
+interface SearxngResult {
+ url?: string;
+ title?: string;
+ content?: string;
+}
+
+function hostOf(url: string): string | null {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return null;
+ }
+}
+
+/** Heuristic filter: https chat-looking results, one per host, capped. */
+export function filterChatCandidates(results: SearxngResult[]): DiscoveryCandidate[] {
+ const seenHosts = new Set();
+ const candidates: DiscoveryCandidate[] = [];
+ for (const result of results) {
+ const url = result.url?.trim() ?? "";
+ if (!url.startsWith("https://")) continue;
+ const host = hostOf(url);
+ if (!host || seenHosts.has(host) || EXCLUDED_HOST_RE.test(host)) continue;
+ const haystack = `${url} ${result.title ?? ""} ${result.content ?? ""}`;
+ if (!CHAT_HINT_RE.test(haystack)) continue;
+ seenHosts.add(host);
+ candidates.push({
+ url,
+ title: result.title?.trim() || host,
+ snippet: (result.content ?? "").trim().slice(0, 200),
+ });
+ if (candidates.length >= MAX_CANDIDATES) break;
+ }
+ return candidates;
+}
+
+export function discoverySearchUrl(searxngUrl: string, query: string): string {
+ const base = searxngUrl.replace(/\/$/, "");
+ return `${base}/search?q=${encodeURIComponent(query)}&format=json`;
+}
+
+export async function searchFreeChatCandidates(options: {
+ searxngUrl: string;
+ query?: string;
+ signal?: AbortSignal;
+ fetchImpl?: typeof fetch;
+}): Promise {
+ const query = options.query?.trim() || DEFAULT_DISCOVERY_QUERY;
+ const doFetch = options.fetchImpl ?? fetch;
+ const url = discoverySearchUrl(options.searxngUrl, query);
+
+ let res: Response;
+ try {
+ res = await doFetch(url, {
+ headers: { Accept: "application/json" },
+ signal: options.signal,
+ });
+ } catch (err) {
+ if (options.signal?.aborted) throw err;
+ // Browser fetch failures (including CORS) surface as opaque TypeErrors.
+ const cause = err instanceof Error ? err.message : String(err);
+ throw new DiscoveryUnavailableError(options.searxngUrl, cause || "network/CORS error");
+ }
+
+ if (!res.ok) {
+ throw new DiscoveryUnavailableError(options.searxngUrl, `HTTP ${res.status}`);
+ }
+
+ let parsed: { results?: SearxngResult[] };
+ try {
+ parsed = (await res.json()) as { results?: SearxngResult[] };
+ } catch {
+ throw new DiscoveryUnavailableError(
+ options.searxngUrl,
+ "non-JSON response — enable the JSON format in SearXNG settings"
+ );
+ }
+
+ const candidates = filterChatCandidates(parsed.results ?? []);
+ if (candidates.length === 0) {
+ throw new DiscoveryEmptyError(query);
+ }
+ return candidates;
+}
+
+/** Event fired with discovered candidates so the pick-list plugin can render them. */
+export const DISCOVERY_RESULTS_EVENT = "llm-fallbacks:discovery-results";
+
+export function broadcastDiscoveryResults(candidates: DiscoveryCandidate[]): void {
+ window.dispatchEvent(
+ new CustomEvent(DISCOVERY_RESULTS_EVENT, { detail: { candidates } })
+ );
+}
diff --git a/webui/src/providers/tiers/settings.ts b/webui/src/providers/tiers/settings.ts
new file mode 100644
index 0000000..9973710
--- /dev/null
+++ b/webui/src/providers/tiers/settings.ts
@@ -0,0 +1,17 @@
+import { STORAGE_KEYS, loadJson, saveJson } from "../../storage-keys";
+import { defaultProviderTierSettings, normalizeTierSettings } from "./defaults";
+import type { ProviderTierSettings } from "./types";
+
+export function loadProviderTierSettings(): ProviderTierSettings {
+ const fallback = defaultProviderTierSettings();
+ const raw = loadJson(STORAGE_KEYS.providerTiers, fallback);
+ return normalizeTierSettings({
+ tiers: raw.tiers ?? fallback.tiers,
+ webRunnerUrl: raw.webRunnerUrl ?? "",
+ searxngUrl: raw.searxngUrl ?? "",
+ });
+}
+
+export function saveProviderTierSettings(settings: ProviderTierSettings): void {
+ saveJson(STORAGE_KEYS.providerTiers, normalizeTierSettings(settings));
+}
diff --git a/webui/src/providers/tiers/types.ts b/webui/src/providers/tiers/types.ts
new file mode 100644
index 0000000..79c898e
--- /dev/null
+++ b/webui/src/providers/tiers/types.ts
@@ -0,0 +1,37 @@
+export type TierId = "quality_api" | "web_ui" | "searxng_discovery" | "proxy_failover";
+
+export interface TierEntry {
+ id: TierId;
+ enabled: boolean;
+}
+
+export interface ProviderTierSettings {
+ tiers: TierEntry[];
+ webRunnerUrl: string;
+ searxngUrl: string;
+}
+
+export interface TierAttempt {
+ tier: TierId;
+ error: string;
+}
+
+export class TierOrchestratorError extends Error {
+ readonly attempts: readonly TierAttempt[];
+
+ constructor(message: string, attempts: readonly TierAttempt[]) {
+ super(message);
+ this.name = "TierOrchestratorError";
+ this.attempts = attempts;
+ }
+}
+
+export class TierSkipError extends Error {
+ readonly tier: TierId;
+
+ constructor(tier: TierId, reason: string) {
+ super(reason);
+ this.name = "TierSkipError";
+ this.tier = tier;
+ }
+}
diff --git a/webui/src/providers/tiers/web-ui-tier.test.ts b/webui/src/providers/tiers/web-ui-tier.test.ts
new file mode 100644
index 0000000..407d54e
--- /dev/null
+++ b/webui/src/providers/tiers/web-ui-tier.test.ts
@@ -0,0 +1,68 @@
+import { describe, expect, it, vi } from "vitest";
+import type { StreamEvent } from "murm-ui";
+import {
+ WebRunnerNotConfiguredError,
+ WebRunnerUnavailableError,
+ runnerChatUrl,
+ streamFromWebRunner,
+} from "./web-ui-tier";
+
+const RUNNER = "http://127.0.0.1:8815";
+
+function sseResponse(lines: string[]): Response {
+ const body = lines.map((l) => `data: ${l}\n\n`).join("") + "data: [DONE]\n\n";
+ return new Response(body, {
+ status: 200,
+ headers: { "Content-Type": "text/event-stream; charset=utf-8" },
+ });
+}
+
+function baseOptions(onEvent: (e: StreamEvent) => void, fetchImpl: typeof fetch) {
+ return {
+ runnerUrl: RUNNER,
+ model: "web-ui",
+ messages: [{ role: "user", content: "hi" }],
+ signal: new AbortController().signal,
+ onEvent,
+ fetchImpl,
+ };
+}
+
+describe("streamFromWebRunner", () => {
+ it("streams OpenAI-shaped SSE deltas from the runner", async () => {
+ const fetchImpl = vi.fn(async () =>
+ sseResponse([
+ JSON.stringify({ choices: [{ delta: { content: "runner " } }] }),
+ JSON.stringify({ choices: [{ delta: { content: "reply" } }] }),
+ JSON.stringify({ choices: [{ delta: {}, finish_reason: "stop" }] }),
+ ])
+ );
+ const events: StreamEvent[] = [];
+ await streamFromWebRunner(baseOptions((e) => events.push(e), fetchImpl));
+
+ const text = events
+ .filter((e): e is Extract => e.type === "text_delta")
+ .map((e) => e.delta)
+ .join("");
+ expect(text).toBe("runner reply");
+ expect(fetchImpl).toHaveBeenCalledWith(runnerChatUrl(RUNNER), expect.anything());
+ });
+
+ it("maps HTTP 501 to a not-configured diagnostic", async () => {
+ const fetchImpl = vi.fn(
+ async () => new Response(JSON.stringify({ error: { message: "no adapter" } }), { status: 501 })
+ );
+ await expect(
+ streamFromWebRunner(baseOptions(() => {}, fetchImpl))
+ ).rejects.toBeInstanceOf(WebRunnerNotConfiguredError);
+ });
+
+ it("maps network failure to an unreachable diagnostic", async () => {
+ const fetchImpl = vi.fn(async () => {
+ throw new TypeError("Failed to fetch");
+ });
+ await expect(
+ streamFromWebRunner(baseOptions(() => {}, fetchImpl))
+ ).rejects.toBeInstanceOf(WebRunnerUnavailableError);
+ });
+});
diff --git a/webui/src/providers/tiers/web-ui-tier.ts b/webui/src/providers/tiers/web-ui-tier.ts
new file mode 100644
index 0000000..5ac693b
--- /dev/null
+++ b/webui/src/providers/tiers/web-ui-tier.ts
@@ -0,0 +1,77 @@
+/**
+ * web_ui tier (R38): stream from a user-run companion runner that automates
+ * free web chat UIs. The runner speaks OpenAI-shaped SSE at
+ * /v1/chat/completions, so the browser client here is a thin fetch + SSE
+ * bridge. Off by default; the public Pages demo never requires it.
+ */
+import type { StreamEvent } from "murm-ui";
+import { emitOpenAiSseAsStreamEvents } from "../sse";
+
+export class WebRunnerNotConfiguredError extends Error {
+ constructor(runnerUrl: string, detail: string) {
+ super(
+ `Web runner at ${runnerUrl} has no adapter configured (${detail}). ` +
+ "Set up runner/runner.config.json — see runner/README.md."
+ );
+ this.name = "WebRunnerNotConfiguredError";
+ }
+}
+
+export class WebRunnerUnavailableError extends Error {
+ constructor(runnerUrl: string, cause: string) {
+ super(
+ `Web runner at ${runnerUrl} is unreachable (${cause}). ` +
+ "Check that the runner is started and the URL in Tiers settings is correct."
+ );
+ this.name = "WebRunnerUnavailableError";
+ }
+}
+
+export function runnerChatUrl(runnerUrl: string): string {
+ return `${runnerUrl.replace(/\/$/, "")}/v1/chat/completions`;
+}
+
+export async function streamFromWebRunner(options: {
+ runnerUrl: string;
+ model: string;
+ messages: { role: string; content: string }[];
+ maxTokens?: number;
+ signal: AbortSignal;
+ onEvent: (event: StreamEvent) => void;
+ fetchImpl?: typeof fetch;
+}): Promise {
+ const doFetch = options.fetchImpl ?? fetch;
+
+ let res: Response;
+ try {
+ res = await doFetch(runnerChatUrl(options.runnerUrl), {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ model: options.model,
+ messages: options.messages,
+ max_tokens: options.maxTokens,
+ stream: true,
+ }),
+ signal: options.signal,
+ });
+ } catch (err) {
+ if (options.signal.aborted) throw err;
+ const cause = err instanceof Error ? err.message : String(err);
+ throw new WebRunnerUnavailableError(options.runnerUrl, cause || "network/CORS error");
+ }
+
+ if (res.status === 501) {
+ const bodyText = await res.text();
+ throw new WebRunnerNotConfiguredError(options.runnerUrl, bodyText.slice(0, 160) || "HTTP 501");
+ }
+ if (!res.ok) {
+ const bodyText = await res.text();
+ throw new WebRunnerUnavailableError(
+ options.runnerUrl,
+ `HTTP ${res.status} — ${bodyText.slice(0, 160)}`
+ );
+ }
+
+ await emitOpenAiSseAsStreamEvents(res, options.onEvent);
+}
diff --git a/webui/src/shell-panels.ts b/webui/src/shell-panels.ts
index 2d977e7..7f7e5ab 100644
--- a/webui/src/shell-panels.ts
+++ b/webui/src/shell-panels.ts
@@ -39,6 +39,7 @@ export function closeShellPanel(_id?: string): void {
export function bindTopBarButtons(): void {
document.getElementById("sysSetting")?.addEventListener("click", () => openShellPanel("failover"));
document.getElementById("byokSetting")?.addEventListener("click", () => openShellPanel("byok"));
+ document.getElementById("tiersSetting")?.addEventListener("click", () => openShellPanel("tiers"));
document.getElementById("explorerSetting")?.addEventListener("click", () => openShellPanel("explorer"));
document.getElementById("closeSet")?.addEventListener("click", () => closeShellPanel());
document.getElementById("sysMask")?.addEventListener("click", (e) => {
diff --git a/webui/src/storage-keys.ts b/webui/src/storage-keys.ts
index fcabb6c..dc10a96 100644
--- a/webui/src/storage-keys.ts
+++ b/webui/src/storage-keys.ts
@@ -3,6 +3,7 @@ export const STORAGE_KEYS = {
guestToken: "llm_fallbacks_guest_token",
defaultModel: "llm_fallbacks_default_model",
apiKeys: "llm_fallbacks_api_keys",
+ providerTiers: "llm_fallbacks_provider_tiers",
} as const;
export function loadJson(key: string, fallback: T): T {
diff --git a/webui/vitest.config.ts b/webui/vitest.config.ts
new file mode 100644
index 0000000..0acce29
--- /dev/null
+++ b/webui/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ setupFiles: ["./vitest.setup.ts"],
+ },
+});
diff --git a/webui/vitest.setup.ts b/webui/vitest.setup.ts
new file mode 100644
index 0000000..6d72d7e
--- /dev/null
+++ b/webui/vitest.setup.ts
@@ -0,0 +1,65 @@
+// In-memory browser seams for Vitest's node environment.
+// Tier settings, API keys, and runtime config persist through localStorage,
+// and FailoverProvider writes window.LLM_FALLBACKS_ROUTE. Neither exists in
+// node, so provide minimal shims instead of pulling in full jsdom.
+
+class MemoryStorage {
+ private store = new Map();
+
+ get length(): number {
+ return this.store.size;
+ }
+
+ clear(): void {
+ this.store.clear();
+ }
+
+ getItem(key: string): string | null {
+ return this.store.has(key) ? (this.store.get(key) as string) : null;
+ }
+
+ key(index: number): string | null {
+ return Array.from(this.store.keys())[index] ?? null;
+ }
+
+ removeItem(key: string): void {
+ this.store.delete(key);
+ }
+
+ setItem(key: string, value: string): void {
+ this.store.set(key, String(value));
+ }
+}
+
+if (typeof globalThis.localStorage === "undefined") {
+ Object.defineProperty(globalThis, "localStorage", {
+ value: new MemoryStorage(),
+ configurable: true,
+ writable: true,
+ });
+}
+
+// Routing metadata broadcasts via window.dispatchEvent; provide inert event
+// methods so provider success paths run under the node environment.
+const eventTarget = globalThis as {
+ dispatchEvent?: (event: unknown) => boolean;
+ addEventListener?: (...args: unknown[]) => void;
+ removeEventListener?: (...args: unknown[]) => void;
+};
+if (typeof eventTarget.dispatchEvent !== "function") {
+ eventTarget.dispatchEvent = () => true;
+}
+if (typeof eventTarget.addEventListener !== "function") {
+ eventTarget.addEventListener = () => {};
+}
+if (typeof eventTarget.removeEventListener !== "function") {
+ eventTarget.removeEventListener = () => {};
+}
+
+if (typeof (globalThis as { window?: unknown }).window === "undefined") {
+ Object.defineProperty(globalThis, "window", {
+ value: globalThis,
+ configurable: true,
+ writable: true,
+ });
+}